Building and publishing container images in CI

Build with buildx and cache layers, tag by commit and branch, push to a registry, scan for vulnerabilities, and generate a software bill of materials.

Building with cache and provenance

jobs:
  image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write            # for keyless signing
    steps:
      - uses: actions/checkout@v4

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

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

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=short
            type=ref,event=branch
            type=semver,pattern={{version}}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true
          sbom: true
  • Tag with the immutable commit SHA for deployment and with a moving tag such as main only for convenience.
  • cache-from: type=gha reuses layers between runs; without it every build reinstalls every dependency.
  • Keep the runtime image separate from the build image with a multi-stage Dockerfile, or you ship a compiler and your source to production.
  • provenance and sbom produce signed attestations that tell you what went into the image.

A Dockerfile that caches well

# syntax=docker/dockerfile:1.7

FROM node:22-alpine AS deps
WORKDIR /app
# copy only the manifests first: this layer changes rarely
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist ./dist
USER app
EXPOSE 3000
HEALTHCHECK CMD wget -qO- http://127.0.0.1:3000/health || exit 1
ENTRYPOINT ["node", "dist/server.js"]
# build for two architectures and push both under one tag
docker buildx build --platform linux/amd64,linux/arm64 \
  -t ghcr.io/example/app:1.4.0 --push .

# inspect what is inside before trusting it
docker sbom ghcr.io/example/app:1.4.0
docker scout cves ghcr.io/example/app:1.4.0 --only-severity critical,high

Scanning and publishing rules

CheckToolFails on
Vulnerable packagesTrivy, Grype, ScoutCritical or high with a fix available
Base image freshnessA scheduled rebuildA base image older than 30 days
Secrets in layersTrivy, gitleaksAny credential-shaped string
Licence complianceSBOM analysisA licence your policy forbids
Image sizeA budget in the pipelineA regression above a threshold
⚠️
Never build an image from a pull request and push it to a mutable tag that production deploys. An attacker who can influence the Dockerfile can replace the latest your deployment uses. Push pull-request images to a scratch namespace, and deploy only immutable digests.

FAQ

Should I deploy a tag or a digest?
A digest. It is content-addressed, so it cannot change under you, and it is the only way to be certain that what runs in production is exactly what was scanned and signed.
How large should a container image be?
Small enough that a node can pull it in seconds — tens of megabytes for most services, not hundreds. Distroless or slim bases, a multi-stage build and pruning development dependencies get you there.

Pipeline security and supply chain Environment promotion and release strategies

Last refreshed 2026-09-18.