Docker Image CI — Build, Tag, Push to GHCR, and Vulnerability Scanning

Docker image building in CI closes the gap between “it works in my container” and “it works in production.” By building Docker images in the pipeline, tagging them with the Git commit SHA, and pushing them to a registry, you create an immutable, traceable artefact that can be deployed to any environment. GitHub Container Registry (GHCR) is free for public repositories and tightly integrated with GitHub Actions — no additional credentials needed when publishing from a workflow.

Container Registry Options

Registry Cost Auth in Actions Best For
GitHub Container Registry (GHCR) Free for public; free tier for private GITHUB_TOKEN — automatic, no setup GitHub-hosted projects
Docker Hub Free for public; paid for private DOCKERHUB_TOKEN secret Public images, wide compatibility
AWS ECR Pay per storage/transfer AWS OIDC or access keys AWS deployments
Google Artifact Registry Pay per storage/transfer GCP OIDC GCP deployments

Image Tagging Strategy

Tag Example Use
Git SHA (short) abc1234 Immutable — trace exact code version
Branch name main, develop Latest build from that branch
Semantic version v1.2.3 Tagged releases
PR number pr-42 Preview deployments for PRs
latest latest Avoid in production — no traceability
Note: The docker/build-push-action action wraps Docker BuildKit with features like automatic layer caching with GitHub’s cache backend (cache-from: type=gha and cache-to: type=gha). This caches intermediate layers between CI runs — the COPY package*.json && RUN npm ci layer is reused if package-lock.json has not changed, turning a 3-minute image build into a 30-second cache hit. BuildKit caching is one of the highest-leverage CI optimisations available.
Tip: Use docker/metadata-action to automatically generate image tags and labels from Git context. This action reads github.ref, github.sha, and other context to produce the correct tags: main branch gets sha-abc1234 and main; a semver tag v1.2.3 gets 1.2.3, 1.2, 1, and latest; a PR gets pr-42. This eliminates the need to manually construct tag strings.
Warning: Do not build and push Docker images on every pull request from external contributors — they have access to the repository but not your secrets, so their PRs will fail on the push step. Structure your workflows to build and test (without pushing) on pull requests, and only push to the registry on merges to the main branch. Use if: github.event_name == 'push' && github.ref == 'refs/heads/main' to gate the push step.

Complete Docker Build and Push Workflow

# .github/workflows/docker.yml — Build and push Docker images

name: Docker Build & Push

on:
    push:
        branches: [main, develop]
        tags:     ['v*']   # also runs on version tags like v1.2.3

env:
    REGISTRY:     ghcr.io
    API_IMAGE:    ghcr.io/${{ github.repository }}/api
    CLIENT_IMAGE: ghcr.io/${{ github.repository }}/client

jobs:
    build-api:
        name:    Build & Push API
        runs-on: ubuntu-latest
        permissions:
            contents: read
            packages: write   # required to push to GHCR

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

            # Log in to GHCR — uses GITHUB_TOKEN automatically
            - name: Log in to GitHub Container Registry
              uses: docker/login-action@v3
              with:
                  registry: ${{ env.REGISTRY }}
                  username: ${{ github.actor }}
                  password: ${{ secrets.GITHUB_TOKEN }}

            # Enable Docker BuildKit (multi-platform, caching)
            - name: Set up Docker Buildx
              uses: docker/setup-buildx-action@v3

            # Generate tags and labels from Git context
            - name: Docker metadata
              id:   meta
              uses: docker/metadata-action@v5
              with:
                  images: ${{ env.API_IMAGE }}
                  tags: |
                      type=sha,prefix=sha-                     # sha-abc1234
                      type=ref,event=branch                    # main, develop
                      type=semver,pattern={{version}}          # 1.2.3
                      type=semver,pattern={{major}}.{{minor}}  # 1.2
                      type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

            # Build and push with GHA layer caching
            - name: Build and push API image
              uses: docker/build-push-action@v5
              with:
                  context: ./api
                  file:    ./api/Dockerfile
                  target:  production
                  push:    true
                  tags:    ${{ steps.meta.outputs.tags }}
                  labels:  ${{ steps.meta.outputs.labels }}
                  cache-from: type=gha                # use GitHub Actions cache
                  cache-to:   type=gha,mode=max       # save all layers to cache
                  build-args: |
                      BUILD_DATE=${{ github.event.head_commit.timestamp }}
                      GIT_SHA=${{ github.sha }}

            - name: Output image digest
              run: echo "API image digest ${{ steps.build.outputs.digest }}"

    build-client:
        name:    Build & Push Angular Client
        runs-on: ubuntu-latest
        permissions:
            contents: read
            packages: write

        steps:
            - uses: actions/checkout@v4

            - uses: docker/login-action@v3
              with:
                  registry: ${{ env.REGISTRY }}
                  username: ${{ github.actor }}
                  password: ${{ secrets.GITHUB_TOKEN }}

            - uses: docker/setup-buildx-action@v3

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

            - name: Build and push Angular client image
              uses: docker/build-push-action@v5
              with:
                  context:    ./client
                  target:     production
                  push:       true
                  tags:       ${{ steps.meta.outputs.tags }}
                  labels:     ${{ steps.meta.outputs.labels }}
                  cache-from: type=gha
                  cache-to:   type=gha,mode=max
                  build-args: |
                      API_BASE_URL=${{ vars.API_BASE_URL }}

    # ── Scan built images for vulnerabilities ──────────────────────────────
    scan-images:
        name:    Security Scan
        runs-on: ubuntu-latest
        needs:   [build-api, build-client]
        steps:
            - name: Run Trivy vulnerability scan on API image
              uses: aquasecurity/trivy-action@master
              with:
                  image-ref:   ${{ env.API_IMAGE }}:sha-${{ github.sha }}
                  format:      sarif
                  output:      trivy-results.sarif
                  severity:    CRITICAL,HIGH
                  exit-code:   '1'   # fail if critical/high vulnerabilities found

            - name: Upload Trivy scan results to GitHub Security tab
              uses: github/codeql-action/upload-sarif@v3
              if: always()
              with:
                  sarif_file: trivy-results.sarif

How It Works

Step 1 — GITHUB_TOKEN Provides Automatic Registry Authentication

GitHub automatically provides a GITHUB_TOKEN secret in every workflow run. This token can authenticate with GHCR when the job has permissions: packages: write. No manual setup, no rotating secrets, no risk of leaked credentials. The token is scoped to the current repository and workflow run, providing minimal required access. Docker Hub and other registries require manual secret setup.

Step 2 — docker/metadata-action Generates Consistent Tags

The metadata action reads the Git event context and generates a set of tags based on your tags: configuration. On a push to main, it produces: sha-abc1234 (immutable, traceable), main (mutable, always latest main), and latest (for the main branch only). On a semver tag v1.2.3, it produces 1.2.3, 1.2, and 1. This covers all common tagging strategies without manual string construction.

Step 3 — BuildKit Layer Caching Reduces Build Time

cache-from: type=gha instructs BuildKit to look for cached layers in GitHub Actions cache. cache-to: type=gha,mode=max saves all layers (not just final image layers) to the cache after the build. On subsequent builds where only source files changed, the npm ci layer is restored from cache — what took 3 minutes takes 30 seconds. The cache key is based on the Dockerfile instruction hash and its inputs.

Step 4 — Trivy Scans Built Images for Known Vulnerabilities

Trivy is an open-source vulnerability scanner that checks Docker images against CVE databases. Running it after building catches security issues in the base image, installed npm packages, and the OS packages. Setting exit-code: '1' fails the CI job if CRITICAL or HIGH vulnerabilities are found — blocking deployment until they are fixed. SARIF output integrates with GitHub’s Security tab, showing vulnerabilities alongside code.

Step 5 — Separate Jobs for API and Client Enable Parallel Builds

Building the API and Angular client images in parallel jobs saves time — both typically take 2–5 minutes and are completely independent. The scan-images job uses needs: [build-api, build-client] to wait for both images before scanning. Total time is max(api_build_time, client_build_time) + scan_time rather than api_build + client_build + scan_time.

Common Mistakes

Mistake 1 — Pushing images on pull requests from forks

❌ Wrong — fork PRs don’t have secrets access, causing failures:

on:
    pull_request:   # forks can't push — always fails for external contributors
jobs:
    build:
        steps:
            - uses: docker/build-push-action@v5
              with: { push: true }  # fails for fork PRs

✅ Correct — push only on direct pushes to branches:

on:
    push:
        branches: [main]   # only internal pushes have secrets

Mistake 2 — Not using BuildKit cache

❌ Wrong — every build downloads all layers from scratch:

- uses: docker/build-push-action@v5
  with:
      push: true
      # Missing cache-from and cache-to — 3+ minute builds every time

✅ Correct — enable GHA cache:

cache-from: type=gha
cache-to:   type=gha,mode=max

Mistake 3 — Using :latest as the only tag

❌ Wrong — no traceability, rollbacks require guessing:

tags: ghcr.io/myorg/api:latest   # which commit? cannot tell

✅ Correct — always include SHA tag:

tags: |
    ghcr.io/myorg/api:sha-${{ github.sha }}   # immutable
    ghcr.io/myorg/api:main                     # mutable pointer

Quick Reference

Task Action / YAML
Login to GHCR docker/login-action@v3 with GITHUB_TOKEN
Enable BuildKit docker/setup-buildx-action@v3
Generate tags docker/metadata-action@v5
Build and push docker/build-push-action@v5
Enable caching cache-from: type=gha, cache-to: type=gha,mode=max
GHCR permissions permissions: contents: read, packages: write
Scan for CVEs aquasecurity/trivy-action@master
SHA tag type=sha,prefix=sha- in metadata-action tags
Build target target: production in build-push-action

🧠 Test Yourself

A Docker image is built in CI with cache-from: type=gha. Only the Angular source files changed — not package.json. How does the build time compare to a cold build, and why?