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
mainonly for convenience. cache-from: type=ghareuses 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.
provenanceandsbomproduce 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,highScanning and publishing rules
| Check | Tool | Fails on |
|---|---|---|
| Vulnerable packages | Trivy, Grype, Scout | Critical or high with a fix available |
| Base image freshness | A scheduled rebuild | A base image older than 30 days |
| Secrets in layers | Trivy, gitleaks | Any credential-shaped string |
| Licence compliance | SBOM analysis | A licence your policy forbids |
| Image size | A budget in the pipeline | A 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.
Related
Pipeline security and supply chain Environment promotion and release strategies
Last refreshed 2026-09-18.