Docker in CI and publishing images

Building in a pipeline, caching layers across runs, tagging with the commit, scanning for vulnerabilities and signing what you ship.

A pipeline that builds and pushes

name: image
on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    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,prefix=sha-
            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
  • The commit SHA tag is the one you deploy: it is unique, traceable and impossible to confuse with another build.
  • Cache from type=gha (or a registry cache) is what makes a pipeline fast; without it every run rebuilds from the base image.
  • Build once, promote the same digest through environments. Rebuilding per environment reintroduces the risk CI was meant to remove.
  • Give the job only the permissions it needs; packages: write is enough to push an image, and nothing should need contents: write here.

Tag strategy

TagExamplePurpose
Commit SHAsha-9f3c1abImmutable reference for a specific build; deploy this
Semantic version1.4.2Human release identity from a git tag
Moving alias1.4, 1Consumers who want patch upgrades automatically
Environmentstaging, productionConvenience pointer, must be a digest under the hood
latestlatestAvoid in automated deploys; it invites drift
Digest@sha256:...The only exact, immutable reference
docker buildx build --push \
  --tag ghcr.io/acme/api:sha-$GIT_SHA \
  --tag ghcr.io/acme/api:1.4.2 \
  --tag ghcr.io/acme/api:1.4 \
  --cache-from type=registry,ref=ghcr.io/acme/api:buildcache \
  --cache-to type=registry,ref=ghcr.io/acme/api:buildcache,mode=max .

# record what you shipped
docker buildx imagetools inspect ghcr.io/acme/api:1.4.2 --format '{{json .Manifest.Digest}}'
  • Publish several tags from one build rather than building several times — same layers, different pointers.
  • Never rebuild to "fix" a tag. Publish a new immutable tag and change the pointer deliberately.
  • Keep the last N build-cache tags; an unbounded cache tag grows forever in the registry.

Scanning, SBOMs and signing

# fail the build on fixable high and critical findings
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed ghcr.io/acme/api:sha-$GIT_SHA

# a software bill of materials, attached as an attestation
docker buildx build --sbom=true --provenance=true --push -t ghcr.io/acme/api:sha-$GIT_SHA .
syft ghcr.io/acme/api:sha-$GIT_SHA -o spdx-json > sbom.json

# sign the digest, keyless in CI, then verify before deploy
cosign sign --yes ghcr.io/acme/api@sha256:8f1c9a4e
cosign verify ghcr.io/acme/api@sha256:8f1c9a4e \
  --certificate-identity-regexp 'https://github.com/acme/api/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

grype ghcr.io/acme/api:sha-$GIT_SHA --fail-on high
💡
Most findings come from the base image and the OS packages inside it, not from your code, so rebuild on a schedule even when your application has not changed. A weekly rebuild on the same commit closes vulnerabilities that appeared after you shipped.

FAQ

Should the pipeline build multi-architecture images on every commit?
Usually not. Build the architecture you deploy on every commit for fast feedback, and add the second architecture nightly or on release, ideally on a native builder rather than under emulation.
How do I keep CI builds fast?
Use BuildKit with an external cache, order the Dockerfile so dependencies are cached above the source copy, keep the context small with .dockerignore, and use a runner with a warm local cache when the registry round trip dominates.

Registries, tags and image distribution BuildKit, cache and multi-platform images

Last refreshed 2026-09-18.