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 |
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.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.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 |