All the techniques in this book deliver full value only when they are applied consistently — not just on a developer’s machine, but on every build in your CI/CD pipeline. This chapter covers how to wire up BuildKit caching in CI, how to enforce image size and security gates, how to choose a registry, and how to build for multiple architectures.
GitHub Actions is used here as the reference CI platform because it has the best native Docker support. The patterns translate directly to GitLab CI, CircleCI, and Jenkins — the key flags and commands are identical; only the YAML syntax differs.
name: Build and push Docker image
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: $
password: $
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: $
tags: ghcr.io/$:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Full workflow: code/github_actions_workflow.yml — includes lint, size gate, and CVE scan steps.
cache-from: type=gha reads from the GitHub Actions cache. cache-to: type=gha,mode=max writes all layers back, including intermediate stages from multi-stage builds. mode=max is essential for multi-stage builds — without it, only the final stage’s layers are cached, and the builder stage is re-run on every build.
On a warm cache, a build that previously took 4 minutes typically completes in 30–60 seconds.
Enforce a maximum image size as a CI check to prevent size regressions:
- name: Check image size
run: python code/check_image_size.py myapp:latest 209715200
209715200 is 200 MB in bytes. check_image_size.py exits non-zero if the image exceeds the threshold, failing the CI job.
Script: code/check_image_size.py
Alternatively, use dive --ci:
- name: Check layer efficiency
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest --ci myapp:latest
This fails if the image efficiency score is below the threshold configured in .dive-ci.yml.
Run hadolint before building, to catch Dockerfile mistakes early:
- name: Lint Dockerfile
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
This action exits non-zero on any finding and posts inline annotations on pull requests.
- name: Scan for vulnerabilities
uses: docker/scout-action@v1
with:
command: cves
image: myapp:latest
only-severities: critical,high
exit-code: true
exit-code: true fails the job if any critical or high CVEs are found. This creates a strong incentive to keep the base image updated and packages minimal.
A consistent tagging strategy makes images traceable and avoids the pitfalls of relying on latest.
tags: |
ghcr.io/$:latest
ghcr.io/$:sha-$
| Tag | Purpose |
|---|---|
sha-<commit> |
Unique, immutable, fully traceable to a commit |
latest |
Convenience alias; useful for pull-based deploys |
v1.2.3 |
Semver release tag; set on release events |
main |
Current state of the main branch |
Never rely on latest alone in production deployments. Use the sha-<commit> tag in Kubernetes manifests or Helm values so rollbacks are unambiguous.
| Registry | Free tier | Storage pricing | Egress pricing | Notes |
|---|---|---|---|---|
| Docker Hub | 1 private repo, rate-limited pulls | $5/mo (Pro) | Free | Rate limits affect CI pulls |
| GitHub Container Registry (ghcr.io) | Free for public repos | $0.008/GB/month | Free within GitHub | Best for GitHub-hosted projects |
| AWS ECR | 500 MB/month free | $0.10/GB/month | $0.09/GB (internet) | Best for AWS deployments |
| Google Artifact Registry | 0.5 GB free | $0.10/GB/month | $0.08/GB | Best for GCP deployments |
| GitLab Container Registry | Included in GitLab | Included | Included | Best for GitLab-hosted projects |
For most projects, ghcr.io is the lowest-friction choice: free, no rate limits within GitHub Actions, and no separate account required.
Cost calculation example: A 100 MB image pushed 10 times/day from 5 nodes costs roughly:
The egress cost dominates — reducing image size from 500 MB to 100 MB reduces this to $2.40/month. At scale, with many services and many nodes, the savings compound.
Teams deploying to AWS Graviton (arm64), Apple Silicon dev machines, or Raspberry Pi need images that run on both linux/amd64 and linux/arm64. BuildKit’s buildx handles this:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Build and push (multi-arch)
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/$:latest
setup-qemu-action installs QEMU for software emulation of the non-native architecture. The build-push-action builds both platform variants in parallel and pushes a multi-platform manifest that Docker automatically resolves to the correct architecture at pull time.
Note: QEMU emulation is slow. For fast CI builds, use native runners for each architecture and merge manifests with docker manifest.
docker/setup-buildx-action + docker/build-push-action with cache-from/cache-to: type=gha,mode=max for fast CI builds.hadolint before the build, dive --ci and docker scout after, as mandatory gates.check_image_size.py to enforce a size budget that prevents silent regressions.sha-<commit> for traceability; use latest only as an alias.ghcr.io is free and rate-limit-free within GitHub Actions — the default best choice for most projects.buildx with --platform linux/amd64,linux/arm64 for multi-architecture support.| ← Chapter 8: Analyzing Images | Table of Contents | Chapter 10: Action Plan → |