GitHub Actions & buildx
Summary
- Combine GitHub Actions with buildx to produce cached, multi-architecture images from one workflow.
- Insight: native per-arch runners beat QEMU emulation for compile-heavy builds; use emulation only for light images.
- Key Concepts: buildx driver, QEMU binfmt emulation, cache exporters (
type=gha,type=registry), manifest lists,docker/build-push-action@v6. - When to Use: any repo whose images run on mixed
amd64/arm64clusters or that needs shared cache across ephemeral runners. - Limitations: QEMU is slow; the GitHub Actions cache has a size cap; a merge step is needed to unify per-arch digests.
Recipe
Provision a BuildKit builder, log in to the registry, then build and push with cache exporters and a platform list.
## Locally, buildx is already available:
docker buildx create --use --name ci
docker buildx build --platform linux/amd64,linux/arm64 \
--cache-to type=registry,ref=ghcr.io/acme/myapp:cache,mode=max \
--cache-from type=registry,ref=ghcr.io/acme/myapp:cache \
-t ghcr.io/acme/myapp:1.0.0 --push .In CI, docker/setup-buildx-action replaces the manual create, and docker/build-push-action wraps the build.
Working Example
This workflow does the common single-runner path: multi-arch via QEMU, with the GitHub Actions cache backend.
name: build
on:
push:
branches: [main]
tags: ["v*"]
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
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 }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/acme/myapp
tags: |
type=semver,pattern={{version}}
type=sha
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: truesetup-qemu-action registers binfmt handlers so a single amd64 runner can emulate arm64. The provenance and sbom flags attach attestations to the pushed manifest.
Deep Dive
How buildx and BuildKit relate
buildx is the CLI front end; BuildKit is the build engine (the default backend in Docker Engine 29.6.1). setup-buildx-action starts a BuildKit instance the workflow talks to.
The builder is what makes multi-arch and advanced cache exporters possible. The legacy classic builder cannot export a manifest list or use type=registry cache.
QEMU emulation vs native runners
QEMU translates instructions so an amd64 host can build arm64 layers. It is convenient and needs no extra infrastructure.
The cost is speed. Emulated compilation can be several times slower, which hurts for Go, Rust, or native Node modules.
The faster pattern is a matrix of native runners - one amd64, one arm64 - each building its own arch, then a merge job that assembles the manifest list.
merge:
needs: [build-amd64, build-arm64]
runs-on: ubuntu-latest
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: |
docker buildx imagetools create -t ghcr.io/acme/myapp:1.0.0 \
ghcr.io/acme/myapp@${{ needs.build-amd64.outputs.digest }} \
ghcr.io/acme/myapp@${{ needs.build-arm64.outputs.digest }}imagetools create stitches per-arch digests into one tag. containerd on each node then pulls the variant matching its architecture.
Cache exporter choices
type=gha uses GitHub's built-in cache, which is simple but capped in size and scoped per repo. It suits most single-repo projects.
type=registry stores cache as an image in your registry, which is unbounded by GitHub limits and shareable across repos and runners. It is the better fit for monorepos.
mode=max exports every intermediate layer; mode=min (the default) exports only layers in the final image. Use mode=max when later builds change early stages.
Provenance and SBOM inline
BuildKit can generate SLSA provenance and an SBOM during the build and attach them as OCI attestations. Setting provenance: true and sbom: true on build-push-action enables this without a separate tool.
These attestations travel with the image in the registry and can be verified downstream by admission policy.
Gotchas
Emulated builds silently succeed but slowly - a job that "hangs" for ten minutes is often QEMU, not a bug. Switch that arch to a native runner.
The GitHub Actions cache has a repository size limit and evicts least-recently-used entries. A monorepo can thrash it; move to type=registry when hit rates drop.
push: true on pull requests leaks images and, worse, can expose credentials to fork PRs. Gate push on github.event_name == 'push'.
Forgetting permissions: packages: write yields a confusing 403 on push to GHCR. Set it at the job or workflow level.
provenance: true changes the pushed artifact into an image index with attestations; some older tools that expect a plain manifest may need updating.
Alternatives
Use a self-hosted or cloud native arm64 runner instead of QEMU when build time dominates. It removes emulation entirely.
Use depot.dev or a persistent BuildKit daemon when you want warm cache and native multi-arch without managing runners. It trades cost for speed and simplicity.
Use Bazel or ko for language-specific builds when Dockerfile layering is not the bottleneck. ko builds Go images without a Dockerfile at all.
Stay on type=gha cache for small single-service repos; reserve type=registry for monorepos where the cache is large and shared.
FAQs
Do I need setup-qemu-action for single-arch builds? No. Only add it when you build a platform different from the runner's native architecture.
Why did my multi-arch tag only contain one arch? You likely pushed each arch to the same tag instead of creating a manifest list. Use imagetools create to merge.
Is type=gha cache shared across branches? It is scoped by cache key and branch, with fallback to the default branch. Cross-branch reuse is partial, not guaranteed.
Can I sign images in the same workflow? Yes. Add a cosign step after push, or rely on the provenance attestation plus a separate signing job.
Why is my arm64 build so slow? It is almost certainly QEMU emulation. Move arm64 to a native runner and merge manifests.
Does build-push-action need a Dockerfile? Yes by default, though you can point file: elsewhere. For Dockerfile-less builds, use a tool like ko or Buildpacks instead.
Related
- Image CI Basics
- Remote Cache
- Provenance & SLSA
- What an Image Build Pipeline Is For
- Build Pipeline Best Practices
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).