Skip to content

Blog

Docker build cache in GitHub Actions: registry vs gha, and multi-arch without QEMU

·

A Docker build in CI starts from nothing. The runner is a fresh machine, so every layer your Dockerfile produces is built again unless something outside the job hands BuildKit its old layers back. Three decisions determine whether that happens: how the Dockerfile is ordered, which cache backend you point BuildKit at, and, if you ship for arm64 as well as amd64, whether the second platform is built by emulation or on a machine of its own.

Docker’s own documentation puts the case for the second point plainly: an external cache becomes almost essential in CI/CD environments, because they usually have little or no persistence between runs. The rest of this post is the detail behind that sentence, with workflows you can paste. We start with the Dockerfile, because no backend can repair a bad one.

Order the Dockerfile before you tune any cache

A cache backend stores layers. It cannot make a layer reusable that you invalidate on every commit. The rule that decides this is short: once one step misses the cache, every step after it is built again. So the order of the Dockerfile is the order in which you decide what a typical commit costs.

This is the layout that works against you, because any change to any file invalidates the dependency install:

FROM node:22-slim WORKDIR /app COPY . . RUN npm ci && npm run build

This is the layout Docker recommends: the dependency manifests first, the install next, the rest of the source last. A commit that touches only source rebuilds only the last two steps.

FROM node:22-slim WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY . . RUN npm run build

The RUN --mount=type=cache line is a second, separate help: Docker describes a cache mount as a persistent cache location for a tool, so that when the install layer does have to be rebuilt, the package manager downloads only what changed. Keep the build context small too. A .dockerignore applies to the whole context, including subdirectories:

.git node_modules

Two quieter traps are worth knowing. For COPY and ADD, the builder compares a checksum calculated from file metadata, not just modification times. For RUN, it compares only the command string, so a cached apt-get update layer is reused even when the package lists have moved on. If you need a fresh run, say so explicitly with --no-cache-filter or a builder prune.

The cache backends side by side

BuildKit can export its cache to several places. Four matter in GitHub Actions. Docker labels the GitHub Actions one beta, and lists S3 and Azure Blob as unreleased, so treat those two as not yet available.

inline embeds the cache in the image itself and pushes it to the same place as the image. It works only with the image exporter and supports only mode=min. It is the least to set up and the least capable.

registry writes the cache to a separate image in a registry. That gives you mode=max, any exporter, and a cache that lives in a registry rather than in GitHub’s Actions cache. The cache reference may be any valid name as long as it is not the one you push the image to. If the reference does not exist yet, the import step fails but the build carries on, so the first run on a new reference is expected to log an error and still succeed.

name: image on: push: branches: [main] permissions: contents: read packages: write jobs: build: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/setup-buildx-action@v4 - uses: docker/build-push-action@v7 with: context: . push: true tags: ghcr.io/example-org/app:latest cache-from: type=registry,ref=ghcr.io/example-org/app:buildcache cache-to: type=registry,ref=ghcr.io/example-org/app:buildcache,mode=max

gha stores the cache in GitHub’s Actions cache. Docker recommends it inside Actions workflows as long as your use falls within the size and usage limits GitHub sets. It needs no registry to set up, and the next section is about what goes wrong with it.

local writes a directory, which you then save and restore yourself with actions/cache. It is the most work and rarely the best option, but it is the one that lets you put the cache under your own key. Docker notes that exporting to a directory that already holds a cache leaves the previous export’s blobs behind, and that the reset=true option that cleans this up needs Buildx 0.35.0 or later.

mode=min or mode=max

In min mode, the default, only layers that end up in the final image are cached. In max mode, all layers are cached, including those of intermediate build stages.

That decides what a multi-stage build can reuse. If your expensive work happens in a builder stage whose output is copied into a slim final stage, min exports only the slim stage’s layers, and the expensive stage is rebuilt from scratch on every run. max keeps it. The cost is a larger cache to export, store and import. Try both on your own Dockerfile and compare the build time and the cache size.

Four ways the gha backend goes wrong

One scope for every image. The scope parameter defaults to buildkit. Docker’s page says it directly: if you build multiple images, each build overwrites the cache of the previous one, leaving only the final cache. Give each image its own scope. Only the cache of the current branch, the base branch and the default branch is visible to a workflow.

name: images on: pull_request: permissions: contents: read jobs: build: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 - uses: docker/build-push-action@v7 with: context: ./api cache-from: type=gha,scope=api cache-to: type=gha,mode=max,scope=api - uses: docker/build-push-action@v7 with: context: ./web cache-from: type=gha,scope=web cache-to: type=gha,mode=max,scope=web

A shared pool. Because the layers go into the Actions cache, they count against the same limits as everything else you cache there. GitHub’s documentation gives the default as 10 GB per repository, removes entries not accessed for over seven days, and when you go over the limit evicts the least recently accessed entries first. A mode=max export of several images can use a large share of that, and eviction then falls on your dependency caches as well as your layers. If you see a gha cache that never seems to hit, look at the repository’s cache list before you look at the Dockerfile.

Read-only runs. Runs triggered by events such as pull_request_target, issue_comment and workflow_run, which resolve to the default branch, get read-only cache access, and Docker documents that cache-to: type=gha can then fail with failed to reserve cache. The same applies to a pull_request workflow triggered with types: [closed] after a merge. A regular pull_request run keeps read and write access. For a run that cannot write, keep cache-from and drop cache-to, and populate the cache from a workflow that can write, such as a push to the default branch. Docker also shows ignore-error=true, but be clear about what it does: it hides every cache export error, not only the access one, and it grants no write access. GitHub warns that giving a low-trust trigger write access reintroduces cache poisoning.

Old tooling. Since 15 April 2025 only version 2 of the GitHub cache service API is supported. Docker lists the minimum versions that speak it: Buildx 0.21.0, BuildKit 0.20.0, Docker Compose 2.33.1, and Docker Engine 28.0.0 if you build with the Docker driver and the containerd image store. If your Buildx is pinned to something older, that is the first thing to change.

One more limit applies to every backend that goes through the Actions cache: BuildKit does not preserve RUN --mount=type=cache contents there. Docker’s documented workaround is a separate action, reproducible-containers/buildkit-cache-dance, which creates temporary containers to extract and inject the mount data. Its example pins that action to a commit SHA, which is worth copying.

Multi-arch: build each platform on its own machine

Docker lists three ways to build a multi-platform image: emulation with QEMU, a builder with multiple native nodes, and cross-compilation in a multi-stage build. Of emulation it says that it can be much slower than native builds, especially for compute-heavy tasks like compilation.

The alternative on GitHub Actions is to build each platform on a runner of that architecture and join the results afterwards.

GitHub provides arm64 Linux runners for this: ubuntu-24.04-arm, ubuntu-22.04-arm and ubuntu-26.04-arm. Standard runners are free and unlimited on public repositories (4 CPUs, 16 GB). On private repositories they have 2 CPUs and 8 GB and are charged per minute; GitHub’s pricing page lists the Linux 2-core arm64 rate as $0.005 a minute, read on 10 November 2026, with each job rounded up to a whole minute.

Here is the hand-written version. Each leg builds one platform and pushes it by digest, without a tag. The legs upload their digests, and a final job creates one tag that points at both. The cache is a registry cache per platform, which keeps the layers out of the Actions cache and so out of its limits and its read-only rules. Provenance attestations are switched off so that each pushed digest is a single image manifest, which is what the merge step expects.

name: image on: push: branches: [main] permissions: contents: read packages: write env: IMAGE: ghcr.io/example-org/app jobs: build: strategy: matrix: include: - platform: linux/amd64 runner: ubuntu-24.04 - platform: linux/arm64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} steps: - name: Name the platform env: PLATFORM: ${{ matrix.platform }} run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV" - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: build uses: docker/build-push-action@v7 with: context: . platforms: ${{ matrix.platform }} provenance: false outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ env.PLATFORM_PAIR }} cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ env.PLATFORM_PAIR }},mode=max - name: Record the digest env: DIGEST: ${{ steps.build.outputs.digest }} run: | mkdir -p "$RUNNER_TEMP/digests" touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" - uses: actions/upload-artifact@v7 with: name: digests-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 merge: needs: build runs-on: ubuntu-24.04 steps: - uses: actions/download-artifact@v8 with: path: ${{ runner.temp }}/digests pattern: digests-* merge-multiple: true - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Merge the platforms into one tag working-directory: ${{ runner.temp }}/digests env: TAG: ${{ github.sha }} run: | refs=() for digest in *; do refs+=("${IMAGE}@sha256:${digest}"); done docker buildx imagetools create -t "${IMAGE}:${TAG}" "${refs[@]}" docker buildx imagetools inspect "${IMAGE}:${TAG}"

If you would rather not maintain that matrix and merge job, Docker publishes a reusable workflow that does it. With distribute: true, the default, it builds one platform per runner and assembles the final image in a finalise phase, and its default mapping sends Linux Arm platforms to ubuntu-24.04-arm and everything else to ubuntu-24.04. This is Docker’s own example, unchanged apart from dropping the semver tag lines:

name: ci on: push: permissions: contents: read jobs: build: uses: docker/github-builder/.github/workflows/build.yml@v1 permissions: contents: read id-token: write with: output: image push: true platforms: linux/amd64,linux/arm64 meta-images: user/app meta-tags: | type=ref,event=branch type=ref,event=pr secrets: registry-auths: | - registry: docker.io username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }}

One limit applies whichever route you take. The default Docker setup on a runner can build and push a multi-platform image, but it cannot load one into the runner’s local image store. To do that you need the containerd image store, which you can enable with docker/setup-docker-action and a daemon configuration with containerd-snapshotter turned on.

Where our runners fit

If you would rather keep the build cache out of GitHub’s shared pool, our Docker layer cache is a registry for your layers that a job on our runners reaches without any flag, hostname or credential in the workflow. You add one step before the build, and every docker build, docker buildx build and docker/build-push-action step after it in the job reads layers from the cache and writes new ones back.

name: image on: pull_request: permissions: contents: read jobs: image: runs-on: runnersio-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v7 - uses: runners-io/setup-docker-builder@v1 - run: docker build -t app .

Each image gets its own cache, worked out from the Dockerfile, the target and the build context, so two images in one job do not overwrite each other. A build that passes its own --cache-from or --cache-to is left alone. A build reads its own branch’s layers first and then the default branch’s, and writes only to its own branch. If the cache cannot be reached, the build runs without it and is only slower; it never fails the build.

The limits are different from the ones above. It is one 10 GiB cache per organisation, shared by all its repositories rather than one per repository, trimmed oldest-used first. It is metered by what it stores, at $0.50/GB-month. It is not offered on Windows runners. On a runner that is not ours, the step does nothing and says so in one notice, so a workflow that runs in both places can keep it.

For the arm64 half of a split build, the labels are listed in the runner labels documentation: runnersio-2vcpu-ubuntu-2404-arm is the label that stands in for ubuntu-24.04-arm on private repositories, and its 4 vCPU sibling for public ones. The ARM image carries Docker with Buildx and Compose. The runner column of the matrix above is where a label like that goes.

What to do first

Fix the Dockerfile order and add a .dockerignore before you touch a backend, because that is free and it changes what any cache can do. Then pick the backend by where the cache should live: registry if you want no shared limit, gha if you want no extra setup and your layers are small, with a scope per image either way. Use mode=max for multi-stage builds and compare it with min. And if the arm64 half of your build runs under QEMU, move it to a runner of its own before you do anything cleverer to the cache.

Native arm64 builds are what the ARM64 runners page is about, and a layer cache that survives between jobs is described on the Docker builds page. The 10 GB limit that the gha backend shares with everything else is explained in the post on the cache limit; our rates are on the pricing page.

Run your CI and your infrastructure on one bill

Connect GitHub

Card required. You pay only for the minutes your jobs run.