Topic 2.5
Building & Pushing Container Images in GitHub Actions
In one line
Most pipelines end by producing a container image. In GitHub Actions, docker/setup-buildx-action, docker/login-action, docker/metadata-action, and docker/build-push-action build multi-platform images with BuildKit, reuse layer caches between runs (type=gha), tag them consistently (commit SHA, branch, version), add labels, provenance, and an SBOM, and push to GHCR or ECR. Build the image once and promote the same digest through environments.
Think of it like this
A bakery's packaging line. Every loaf (build) goes into a labelled bag (tags and labels) with a batch number (commit SHA), a list of ingredients (SBOM), and a seal showing where it was made (provenance), then goes to the shop's storeroom (registry).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- buildx
- Docker's build command using BuildKit, supporting multi-platform builds and advanced caching.
- GHCR
- GitHub Container Registry, at ghcr.io.
- Image digest
- The sha256 identifier of an image's exact content, which never changes.
- Multi-platform image
- One image tag containing builds for several CPU architectures.
- Provenance attestation
- Signed metadata describing how, where, and from which source an image was built.
- SBOM
- Software Bill of Materials: a list of the components inside an image.
Step by step
01The image job
After tests pass on main, Tiffin builds the web image for both architectures, tags it from Git, pushes it to GHCR, and records the digest as a job output for deployment.
image:
needs: [unit-tests, integration-tests]
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions: { contents: read, packages: write, id-token: write, attestations: write }
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- 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/tiffin-team/web
tags: |
type=sha,format=short
type=ref,event=branch
type=semver,pattern={{version}}
- id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: mode=max
sbom: true02What landed in the registry
The pushed image has one tag per Git reference, a manifest list for both architectures, and attestations attached.
03The cache at work
On a code-only change, the dependency layers come from the GitHub Actions cache. The image job drops from 9 minutes (cold, two architectures) to under 3.
Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Permission denied pushing to GHCR
The workflow sets permissions: { contents: read } at the top, and the image job doesn't override it.
Myth vs fact
Myth
Building the image again for production is fine, it's the same Dockerfile.
Fact
A rebuild can pull different base images or dependencies and produce different content. Deploy the digest that was tested.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Building ARM images under QEMU emulation is slow. For large builds, use native ARM runners (hosted
ubuntu-24.04-armor self-hosted) per platform, push by digest, and merge them into one multi-platform manifest.
Remember this
- 1
buildx and BuildKit: the modern Docker builder with parallel stages, cache mounts, secrets, and multi-platform builds (
linux/amd64,linux/arm64) (Docker course, Topic 10.1). - 2
Login: to GHCR with
GITHUB_TOKEN(needspackages: write), or to ECR with AWS credentials from OIDC (Topic 7.2), not stored keys. - 3
Tags:
docker/metadata-actioncreates tags from Git:sha-8f3a1c2, branch names, PR numbers, and SemVer from tags (2.4.0,2.4). Avoid relying onlatest. - 4
Layer cache:
cache-from: type=ghaandcache-to: type=gha,mode=maxstore layers in the Actions cache, so unchanged layers (dependencies) aren't rebuilt. - 5
Provenance and SBOM:
provenance: mode=maxandsbom: trueattach build attestations, describing how and from what the image was built (Topic 7.3). - 6
Build once, promote by digest: deploy the exact image digest (
@sha256:...) built and tested in CI, not a rebuild per environment (Phase 5).
Explain it without notes
Why tag images with the commit SHA?
What does cache-to: type=gha,mode=max do?
Practice
Build and push an image to GHCR from a workflow with SHA and branch tags.
Make the image build reuse its cache and measure the difference.
Trade-offs
- ↔
Multi-platform builds serve ARM and x86 from one tag but take longer, especially under emulation. Attestations and SBOMs add build time and storage, in exchange for supply-chain transparency.
Done when you can
I build, tag, and push images with the Docker actions.
My image builds use layer caching.
I deploy images by digest and attach provenance and SBOMs.