Choosing the right cache strategy for docker/build-push-action
A Docker image build that starts from zero on every push in GitHub Actions wastes minutes that can be almost entirely eliminated with the right cache strategy for docker/build-push-action, once you understand the differences between GitHub Actions cache, registry cache, and inline cache.
Table of Contents
- 1. Why Docker builds are slow in GitHub Actions without a cache
- 2. The gha cache backend: native for GitHub Actions
- 3. Registry cache as a portable alternative
- 4. Inline cache: the simplest but limited approach
- 5. The difference from GitLab CI: no native gha equivalent
- 6. Setting up multi-platform builds in the pipeline
- 7. Matrix build as a faster alternative to QEMU
- 8. Making cache effectiveness visible instead of assuming it
- 9. Cache invalidation and Dockerfile ordering
- 10. Summary
- 11. FAQ
1. Why Docker builds are slow in GitHub Actions without a cache
Every GitHub Actions job runs by default on a freshly provisioned, isolated runner with no prior Docker layer history whatsoever. Unlike a local development machine, where Docker layers sit in the local cache for weeks, every workflow run technically starts from zero unless a persistent cache mechanism is explicitly set up. Without one, even an unchanged npm install layer rebuilds completely on every run, costing several minutes per build on larger projects.
The official docker/build-push-action from Docker itself supports several cache backends via the cache-from and cache-to parameters, which are built on top of BuildKit. Choosing the right backend is not a minor detail: a poorly chosen cache backend can even slow the build down, because uploading and downloading the cache itself takes time that eats back into the speed gain.
2. The gha cache backend: native for GitHub Actions
Since BuildKit 0.11, a dedicated cache backend called gha exists that talks directly to the GitHub Actions Cache API, the same infrastructure that actions/cache also uses for other build artifacts. The big advantage: no external registry account and no extra secret is required, the cache lives entirely inside GitHub and is automatically discarded after roughly seven days of inactivity or once the repository-wide cache limit (currently 10 GB) is reached.
Setup happens via the docker/setup-buildx-action action combined with the parameters cache-from: type=gha and cache-to: type=gha,mode=max in docker/build-push-action. The mode=max parameter is crucial here: by default (mode=min), BuildKit only caches the final layers of the image, while mode=max also caches intermediate layers from multi-stage builds, which makes the difference between an almost empty cache and a genuinely effective one on multi-stage Dockerfiles.
name: Build and push Docker image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/myorg/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
3. Registry cache as a portable alternative
The registry cache backend (type=registry) stores cache layers as a separate, specially tagged image directly in the same container registry as the actual image. The key advantage over gha: this cache is not tied to GitHub Actions and works identically in GitLab CI, Jenkins, or any other CI platform, as long as it also has access to the same registry. For teams switching between CI systems or running several pipelines in parallel, that is a significant advantage over the GitHub-specific gha backend.
The downside is an extra network round trip: the cache has to be explicitly uploaded to the registry on every build and downloaded again on the next one, which noticeably costs time on slow network connections or with very large images. In addition, every cache push creates an extra tag or manifest in the registry, which needs to be factored in with restrictive registry quotas or per-layer storage cost models.
- name: Build and push with registry cache
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/myorg/myapp:latest
cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max
4. Inline cache: the simplest but limited approach
The third option is inline cache (type=inline), where cache metadata is embedded directly into the pushed image itself, without a separate cache image or external storage. This is the simplest configuration of all, since no second manifest or extra tag is needed, the actual image doubles as its own cache. The critical downside: inline cache only supports mode=min, so intermediate layers from multi-stage builds are never cached, which gives away a substantial part of the possible cache benefit on more complex Dockerfiles.
In practice, inline cache is best suited for very simple, single-stage Dockerfiles or as a quick starting point when a team is not yet ready to set up a separate cache backend. For production pipelines with multi-stage builds, gha or registry cache with mode=max is almost always the better choice, since the speed gain from complete layer caching clearly outweighs the extra configuration effort.
- name: Build and push with inline cache
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: myregistry/myapp:latest
cache-from: type=registry,ref=myregistry/myapp:latest
cache-to: type=inline
5. The difference from GitLab CI: no native gha equivalent
Anyone coming from GitLab CI knows primarily two approaches there: the GitLab registry cache (functionally identical to the type=registry backend that also works in GitHub Actions) and Docker-in-Docker with a mounted cache volume on a self-managed runner. The latter only works to a limited extent in GitHub Actions, because GitHub-hosted runners are fully re-provisioned for every job and offer no persistent filesystem between runs, unlike a self-hosted GitLab runner that often runs permanently on the same machine.
The gha cache backend exists precisely as GitHub's specific answer to this problem: it simulates a persistent cache through the hosted cache API, without needing a permanently running, self-managed runner. Anyone who wants to keep workflows portable between GitHub Actions and GitLab CI should therefore rely on the registry cache backend, which works identically on both platforms, instead of depending on the GitHub-exclusive gha backend.
6. Setting up multi-platform builds in the pipeline
For images that need to run both on linux/amd64 servers and on linux/arm64 systems such as Apple Silicon developer machines or ARM-based cloud instances, docker/build-push-action supports the platforms parameter directly. This requires QEMU emulation via docker/setup-qemu-action, since GitHub-hosted runners themselves only run on amd64 architecture and the ARM variant of the image has to be built through emulation, which noticeably slows the build down compared to a native build on matching hardware.
For projects with frequent multi-platform builds, switching to native ARM runners, which GitHub now offers as a hosted option, or a matrix build approach pays off, where each platform is built in its own parallel job on matching native hardware and the results are then merged into a shared multi-arch manifest via docker buildx imagetools create. This approach is significantly faster than QEMU emulation, but requires an extra merge step in the pipeline.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-platform image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/myorg/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
7. Matrix build as a faster alternative to QEMU
A matrix build splits the multi-platform build into several independent jobs, each running on a runner matching the target architecture and producing only a digest file instead of a full push. A final job collects all digests and uses buildx imagetools create to assemble a shared multi-arch manifest that bundles every platform under a single tag. This approach avoids QEMU emulation entirely and is often three to four times faster for ARM builds.
The extra cost lies in additional pipeline complexity: instead of a single build step, a matrix strategy, an artifact exchange between jobs for the digest files, and a separate merge job are needed. For smaller projects with occasional ARM builds, the simpler QEMU approach is usually the more pragmatic choice, while for projects with very frequent releases and large images, the matrix variant quickly pays for itself through the saved build time.
jobs:
build:
strategy:
matrix:
platform: [linux/amd64, linux/arm64]
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
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 }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=ghcr.io/myorg/myapp,push-by-digest=true,name-canonical=true,push=true
merge:
needs: build
runs-on: ubuntu-latest
steps:
- name: Create manifest list
run: |
docker buildx imagetools create \
-t ghcr.io/myorg/myapp:latest \
ghcr.io/myorg/myapp@sha256:AAA ghcr.io/myorg/myapp@sha256:BBB
8. Making cache effectiveness visible instead of assuming it
Without concrete measurement, whether a cache backend actually helps remains pure guesswork. BuildKit reports in its build log for every layer whether it was served from cache (CACHED) or had to be rebuilt, which can be read directly in the GitHub Actions job log without any extra tooling. Looking at total build time alone is not enough, since it fluctuates significantly depending on how loaded the GitHub infrastructure currently is. The number of layers marked CACHED relative to the total number of layers in the Dockerfile is a far more meaningful signal.
A simple but effective practical test is running the same workflow twice in a row without any code change and comparing the build times. If the second run is not significantly faster than the first, that points to a misconfigured cache backend, commonly a missing mode=max, a wrong ref value for registry cache, or a Dockerfile whose layer order keeps invalidating the cache. This two-run test belongs in every introduction of a new cache backend, before relying on its effectiveness.
9. Cache invalidation and Dockerfile ordering
Regardless of the chosen backend, the fundamental rule of Docker layer caching still applies: any change to a layer automatically invalidates every following layer in the cache. A Dockerfile that copies the entire source tree first and only then runs npm install invalidates the dependency installation layer on every code change, even if package.json did not change at all. The correct order, copying and installing dependency files first, then copying the rest of the code, is therefore the basic prerequisite for any cache backend to have an effect at all.
Another common mistake is a build argument such as a timestamp or a commit SHA set early in the Dockerfile via ARG, which invalidates every following layer on every build, even if that value is only actually needed later in the build. Such volatile build arguments should be placed as late as possible in the Dockerfile, ideally right before the layer that actually needs them, to keep as many preceding layers servable from cache as possible.
| Cache backend | Storage location | Cross-platform portable | Supports mode=max |
|---|---|---|---|
type=gha |
GitHub Actions Cache API | No, GitHub-exclusive | Yes |
type=registry |
Separate cache image in registry | Yes, works everywhere | Yes |
type=inline |
Embedded in the image itself | Yes, works everywhere | No, mode=min only |
| No cache | No caching | N/A | N/A |
Mironsoft
Container infrastructure, CI pipelines and deployment automation
Docker setups that hold up across the team and in production?
We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.
Dockerfile Review
Systematically optimizing multi-stage builds, layer caching and image size.
Security Audit
Hardening container isolation, secrets handling and image scanning against real attack surfaces.
CI/CD Integration
Building build pipelines, registries and deployment strategies for reproducible releases.
10. Summary
GitHub Actions Docker Cache: Key Takeaways
gha backend
Native for GitHub Actions, no registry account needed, but GitHub-exclusive.
Registry cache
Portable between GitHub Actions and GitLab CI, costs a network round trip.
mode=max
Required for effectively caching intermediate layers in multi-stage builds.
Multi-platform
QEMU for simple setups, matrix build with native runners for maximum speed.