Image CI Basics
This section covers the minimum viable image pipeline: build and test on every pull request, and push a digest-addressed image to the registry only from protected branches or tags.
Prerequisites
- Docker Engine 29.6.1 with BuildKit as the default backend (no extra config needed).
- buildx (bundled with modern Docker) for cache exporters and multi-arch.
- A container registry (GHCR, ECR, Artifact Registry, or Docker Hub) with a robot/OIDC credential.
- A CI runner - GitHub Actions is used here, but the shape is identical on GitLab CI or Buildkite.
Quick check that BuildKit is active:
docker buildx version
docker build --help | grep -i buildkitBasic Examples
1. A cache-friendly Dockerfile
Order instructions from least to most frequently changing so the dependency layer stays cached.
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build- The
COPY package*.jsonthennpm cisplit means source edits do not reinstall dependencies. node:22-slimis smaller than the full image; pin to a digest later for reproducibility.npm ciuses the lockfile for deterministic installs, unlikenpm install.WORKDIRcreates and enters the directory in one step.
2. A multi-stage image that ships non-root
Keep build tools out of the runtime image and drop root.
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
USER nginx
EXPOSE 8080--from=buildcopies only the compiled output, leavingnode_modulesbehind.- The final image contains no compiler or package manager, shrinking attack surface.
USER nginxruns as an unprivileged user, satisfying restricted Pod Security Standards.- Smaller runtime images pull faster onto nodes and reduce CVE exposure.
3. Build locally before wiring CI
Reproduce the CI build on your machine first.
docker build -t myapp:dev .
docker run --rm -p 8080:8080 myapp:dev- A green local build is the baseline your pipeline must match.
--rmcleans up the container after it exits.- If it fails locally, it will fail in CI - debug here where it is faster.
4. Build on every pull request
The core rule: validate on PRs, never push from them.
name: image-ci
on:
pull_request:
push:
branches: [main]
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:cipush: falsemeans PR builds verify the Dockerfile without polluting the registry.setup-buildx-actionprovisions a BuildKit builder with cache support.- The workflow also triggers on
mainandv*tags for the publish path.
5. Push only from main and tags
Gate the push on the event so forks and PRs cannot publish.
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ghcr.io/acme/myapp:${{ github.sha }}pushis true only forpushevents, so pull requests stay build-only.- Tagging by
github.shagives every merge a unique, traceable image. - Registry credentials are never exposed to untrusted PR runs this way.
6. Authenticate to the registry with OIDC
Prefer short-lived tokens over long-lived secrets.
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}GITHUB_TOKENis scoped to the workflow run and expires when it ends.- For ECR or Artifact Registry, use the cloud's OIDC action to mint a token with no stored secret.
- Give the token
packages: writepermission only on the publish path.
7. Tag by digest, not by latest
Capture the immutable reference the deploy will use.
docker buildx imagetools inspect ghcr.io/acme/myapp:$(git rev-parse HEAD)- The output includes
Digest: sha256:..., the value to deploy. - Deploying by digest guarantees the exact bytes and enables precise rollback.
- Keep human-readable tags as aliases, but let the digest be the contract.
Intermediate Examples
8. Add a vulnerability scan gate
Fail the build on high or critical CVEs before publishing.
- uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: myapp:ci
exit-code: "1"
severity: HIGH,CRITICALexit-code: "1"makes findings fail the job rather than warn.- Scoping to
HIGH,CRITICALavoids blocking on unactionable low-severity noise. - Run the scan after build but before push so nothing vulnerable reaches the registry.
9. Enable a build cache for faster runs
Reuse layers across ephemeral runners with a registry-backed cache.
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:ci
cache-from: type=gha
cache-to: type=gha,mode=maxtype=ghauses GitHub's cache backend so cold runners restore prior layers.mode=maxexports intermediate stage layers, not just the final one.- Cache turns a multi-minute dependency install into a near-instant restore.
10. Reproducible tags for release
Attach both a semantic tag and the commit SHA on tag builds.
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/acme/myapp
tags: |
type=semver,pattern={{version}}
type=shametadata-actionderives tags and OCI labels from the git ref automatically.- A
v1.4.0tag produces1.4.0plus asha-<short>tag for traceability. - Consistent labels feed provenance and make images self-describing in the registry.
11. Keep build context small with .dockerignore
Exclude files that should never enter a layer.
## file: .dockerignore
.git
node_modules
*.env
Dockerfile- A smaller context uploads faster to the BuildKit builder and keeps builds quick.
- Excluding
node_modulesavoids shipping host-built binaries that may not match the image. - Excluding
*.envprevents secrets from leaking into a cached layer. - The daemon skips ignored paths entirely, so they cannot invalidate the cache.
12. Promote the same digest across environments
Build once, then move the identical image through staging and production.
DIGEST=$(docker buildx imagetools inspect \
ghcr.io/acme/myapp:$(git rev-parse HEAD) --format '{{.Manifest.Digest}}')
echo "deploy ghcr.io/acme/myapp@$DIGEST"- Capturing the digest once means staging and production run byte-identical images.
- Never rebuild for production - a rebuild can pull a changed base and drift.
- The digest becomes the value your Helm values or GitOps manifest references.
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).