CI/CD pipeline diagram showing automated build and push workflow

CI/CD Pipeline: Automate Docker Builds with GitHub Actions

You can build and push images manually. But you won’t. You’ll forget, you’ll make mistakes, you’ll skip it when you’re tired.

CI/CD means: push code, image builds automatically. Every time. No manual steps. No forgetting.

This post sets up automated pipelines using GitHub Actions or Gitea Actions. Push code, get a fresh container image in your registry.

DevOps Skills Demonstrated

  • CI/CD pipeline design and implementation
  • GitHub Actions and Gitea Actions workflows
  • Automated testing and build processes
  • Docker image build automation

Market Value: CI/CD expertise is the core of DevOps and is essential for roles at £60-80k+

What You’ll Learn

  • What CI/CD actually means
  • GitHub Actions basics
  • Gitea Actions (self-hosted alternative)
  • Building and pushing Docker images automatically
  • Testing in the pipeline

What Is CI/CD?

CI: Continuous Integration

Every code push triggers:

  • Build the project
  • Run tests
  • Catch problems early

CD: Continuous Delivery/Deployment

After CI passes:

  • Build container image
  • Push to registry
  • (Optionally) Deploy automatically

Why It Matters

Manual ProcessAutomated Pipeline
Forget to buildBuilds every push
Version mismatchTagged automatically
“Works on my machine”Same build environment every time
Deploy old code accidentallyAlways latest build

GitHub Actions

Hour 0-1: How It Works

  1. Create .github/workflows/build.yml in your repo
  2. Define triggers (on push, on PR, etc.)
  3. Define jobs (steps to run)
  4. GitHub runs it on their infrastructure

Basic Workflow Structure

name: Build and Push

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Do something
        run: echo "Hello, CI/CD!"

Hour 1-3: Docker Build Workflow

Create .github/workflows/docker.yml:

name: Build and Push Docker Image

on:
  push:
    branches: [main]
    tags: ['v*']

env:
  REGISTRY: registry.yourdomain.com
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha,prefix=

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Setting Up Secrets

In GitHub: Settings > Secrets and variables > Actions > New repository secret

Add:

  • REGISTRY_USERNAME – Your registry username
  • REGISTRY_PASSWORD – Your registry password or token

What This Does

  1. Triggers on push to main or version tags
  2. Logs into your private registry
  3. Builds the Docker image
  4. Tags based on branch/version/commit
  5. Pushes to your registry
  6. Caches layers for faster builds

Gitea Actions (Self-Hosted Alternative)

If you use Gitea and want fully self-hosted CI/CD:

Enable Actions in Gitea

In app.ini:

[actions]
ENABLED = true

Set Up a Runner

You need a runner (machine that executes jobs):

# Download act_runner
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64
chmod +x act_runner-linux-amd64

# Register runner
./act_runner-linux-amd64 register --instance https://git.yourdomain.com

# Run (or set up as service)
./act_runner-linux-amd64 daemon

Gitea Actions Workflow

Create .gitea/workflows/build.yml (note: .gitea not .github):

name: Build and Push

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Login to Registry
        run: |
          echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.yourdomain.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin

      - name: Build and Push
        run: |
          docker build -t registry.yourdomain.com/${{ gitea.repository }}:latest .
          docker build -t registry.yourdomain.com/${{ gitea.repository }}:${{ gitea.sha }} .
          docker push registry.yourdomain.com/${{ gitea.repository }}:latest
          docker push registry.yourdomain.com/${{ gitea.repository }}:${{ gitea.sha }}

Secrets in Gitea

Repository > Settings > Actions > Secrets

Add your registry credentials.

Adding Tests

Hour 3-4: Run Tests Before Building

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

  build:
    needs: test  # Only run if tests pass
    runs-on: ubuntu-latest
    steps:
      # ... build steps

Basic Test Script

Add to package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Even simple tests catch obvious problems.

Workflow Triggers

On Push

on:
  push:
    branches: [main, develop]

On Tags (Releases)

on:
  push:
    tags: ['v*']

On Pull Request

on:
  pull_request:
    branches: [main]

Manual Trigger

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version tag'
        required: true

Image Tagging Strategies

Automatic Tags with metadata-action

The docker/metadata-action creates smart tags:

tags: |
  type=ref,event=branch      # Branch name (main, develop)
  type=semver,pattern={{version}}  # v1.0.0 → 1.0.0
  type=sha,prefix=           # Commit SHA
  type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

Result for push to main:

  • registry/user/app:main
  • registry/user/app:sha-abc1234
  • registry/user/app:latest

Result for tag v1.2.3:

  • registry/user/app:1.2.3
  • registry/user/app:sha-abc1234

Caching for Speed

Layer Caching

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

First build: ~3-5 minutes. Cached builds: ~30-60 seconds.

npm Cache

- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'

Caches node_modules between runs.

Debugging Failures

View Logs:

GitHub: Actions tab > Click workflow run > Click job > Expand steps

Common Issues:

“denied: requested access to the resource is denied”

  • Check secrets are set correctly
  • Check registry URL matches
  • Check username/password

Build fails but works locally:

  • Check Dockerfile doesn’t assume local files
  • Check all needed files are committed
  • Check .dockerignore isn’t excluding needed files

Tests fail:

  • Check test command in package.json
  • Tests might need environment variables

Run Locally:

Test GitHub Actions locally with act:

brew install act  # or equivalent
act -j build

Real Example: Complete CI/CD Workflow

Full workflow with tests – .github/workflows/ci.yml:

name: CI/CD Pipeline

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  REGISTRY: registry.yourdomain.com
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint --if-present

      - name: Run tests
        run: npm test --if-present

  build:
    needs: test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What You’ve Built

Your workflow is now:

Push code > Tests run > Image builds > Pushed to registry

Automatically. Every time. No manual steps.

How to Talk About This in Interviews

On your resume:

  • “CI/CD pipeline design with GitHub Actions”
  • “Automated Docker build and push workflows”
  • “Build caching and optimization strategies”

In interviews:

“I’ve implemented CI/CD pipelines that automatically test, build, and push Docker images on every commit. I use GitHub Actions with layer caching to keep build times under a minute. Tests run first, and builds only proceed on success. Images are tagged with both version numbers and commit SHAs for traceability.”

The Journey So Far

  • Step 1: Built app with AI tools
  • Step 2: Set up local development environment
  • Step 3: Containerize with Docker
  • Step 4: Push to private registry
  • Step 5: Automate with CI/CD (You are here)
  • Step 6: Deploy to your infrastructure

Practical Exercise

Today:

  1. Create .github/workflows/docker.yml
  2. Add registry secrets in GitHub
  3. Push a commit to main
  4. Watch Actions tab for build
  5. Verify new image in registry

Verification:

  • Green checkmark in GitHub Actions
  • New image tag in your registry
  • No manual docker build needed

Automation isn’t laziness. It’s reliability. The machine won’t forget to build.

Enjoyed this guide?

New articles on Linux, homelab, cloud, and automation every 2 days. No spam, unsubscribe anytime.

Scroll to Top