Docker Buildx Cache in GitHub Actions: gha, registry
Last updated
To cache Docker builds in GitHub Actions, use docker/setup-buildx-action and docker/build-push-action with cache-from: type=gha and cache-to: type=gha,mode=max. That stores BuildKit's layer cache in the GitHub Actions cache. Four backends exist (gha, registry, inline, local); this guide shows each, when to pick it, and the limits.
If your build is slow and you are not sure why, start with Docker build slow in CI. For the concepts, see Docker layer caching in CI.
The default setup: type=gha
name: Docker
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v7
with:
context: .
push: false
tags: app:ci
cache-from: type=gha
cache-to: type=gha,mode=max
Setting up Buildx is required. The Docker docs (checked October 2026) say the default docker driver works with the gha backend only when the containerd image store is enabled; otherwise you need a different driver, and setup-buildx-action creates one. The cache URL and token are filled in automatically inside a workflow.
Parameters that matter
From the Docker gha backend docs:
| Parameter | Default | Meaning |
|---|---|---|
scope | buildkit | Name of the cache object. Use one per image. |
mode | min | min exports only layers of the final image; max exports all layers of all stages. |
ignore-error | false | Do not fail the build if exporting the cache fails. |
Use mode=max for multi-stage builds. With min, intermediate stages (your dependency install, your compile step) are not in the cache, and those are the expensive ones. max stores them at the cost of a larger cache.
Set a scope per image. The default scope is shared, so two images built in one repository overwrite each other's cache:
cache-from: type=gha,scope=web
cache-to: type=gha,scope=web,mode=max
In a matrix, include the matrix value in the scope, for example scope=build-${{ matrix.platform }}.
The docs also describe a newer cache service API (v2) selected by URL or a version setting. If you see cache errors after a GitHub-side change, check that your Buildx and BuildKit versions are current, because older ones target the old API.
Throttling
Heavy cache export can hit API rate limits. The build action passes github-token (default secrets.GITHUB_TOKEN) to Buildx to help avoid throttling; leave it set.
Limits of type=gha
- It lives in the GitHub Actions cache, so it shares the 10 GB per-repository quota with npm, pip and everything else. A
mode=maxDocker cache can use a few GB on its own. - Entries not used for 7 days are removed, and the least recently used entries go first when the repository is over quota. See cache limits.
- PRs read the base branch cache and write their own scoped entries. Build on
maintoo so the base cache stays warm. - Self-hosted runners may not have access to the GitHub cache service, depending on setup. Check before relying on it; a registry cache avoids the question.
type=registry
Store the cache as an image in a container registry:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
Use this when the cache is large, when runners are not on GitHub's cache network, or when you want local developers and CI to share a cache. It does not count against the 10 GB Actions quota (registry storage rules apply instead). Pushing and pulling a big cache over the network costs time, so keep the registry close to the runner. Some registries do not support cache manifests; check yours.
type=inline
Embeds cache metadata in the image you push, so cache-from can use the image itself:
cache-from: type=registry,ref=ghcr.io/org/app:latest
cache-to: type=inline
It is the simplest option and needs no extra tag. The trade-off: only mode=min is supported, so intermediate stages of a multi-stage build are not cached. Fine for a single-stage Dockerfile, weak for multi-stage.
type=local
Writes the cache to a directory, which you save with actions/cache:
- uses: actions/cache@v6
with:
path: /tmp/.buildx-cache
key: buildx-${{ runner.os }}-${{ github.sha }}
restore-keys: |
buildx-${{ runner.os }}-
- uses: docker/build-push-action@v7
with:
context: .
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
This is the legacy pattern. It never shrinks on its own because each export adds to the directory, so people add a step to move -new over the old directory. type=gha replaces it for most cases. Use local if you need a cache that you control entirely with actions/cache keys.
Which backend?
| Need | Use |
|---|---|
| Simple, small to medium images | type=gha,mode=max with a scope |
| Large cache, shared with laptops, or not on GitHub cache | type=registry,mode=max |
| Single-stage Dockerfile, minimal config | type=inline |
Full control through actions/cache | type=local |
Cache mounts and why they are lost on fresh runners
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt gives a build step a persistent directory, so pip does not redownload everything when the layer reruns. That is a great feature on your laptop or on a long-lived builder.
On GitHub-hosted runners it does nothing for you by default. A cache mount lives in BuildKit's local state on the builder, not in the layer cache that cache-to exports. A fresh runner means a fresh BuildKit, so the mount is empty on every job. type=gha and type=registry export layers, not mounts.
Options if you rely on cache mounts:
- Treat them as a local optimisation only and rely on layer caching in CI.
- Persist the mount directory yourself. Third-party tooling such as the
reproducible-containers/buildkit-cache-danceaction exists to save and restore cache mount contents throughactions/cache. Evaluate it for your security needs before adopting it. - Use a persistent builder. Dedicated build services keep BuildKit state between runs. Depot does this for Docker builds; see the Depot comparison. A self-hosted runner with a persistent disk can also work.
Make the cache hit
- Order the Dockerfile so dependency layers come first (see slow Docker builds).
- Keep
.dockerignoretight. - Use
--progress=plainor the build summary to look forCACHEDlines. - Build on
mainso branches have a base to read.
compiler.dev keeps a Docker layer cache on the machine between jobs, so a plain docker build can reuse layers without exporting them. Switch with runs-on: [self-hosted, Linux, compiler-ci]; see pricing.
FAQ
Should I use mode=max or mode=min?
max for multi-stage builds, since it caches intermediate stages. min is smaller and fine for single-stage images.
Why do two images overwrite each other's cache?
They share the default scope buildkit. Give each a unique scope.
Can I use type=gha on self-hosted runners?
It depends on whether the runner can reach GitHub's cache service. Test on a small build, or use a registry cache.
Does the gha cache count against my 10 GB limit?
Yes. It is stored in the same Actions cache.
Do I need setup-buildx-action?
For type=gha and most backends, yes. The default driver does not support them without the containerd image store.
Related
- Cache Cargo and Rust Builds in GitHub ActionsCache the Cargo registry, git dependencies and target directory in GitHub Actions with Swatinem/rust-cache or actions/cache, and avoid bloated caches.
- Cache Go Modules and Build Cache in GitHub ActionsCache Go modules (GOMODCACHE) and the build cache (GOCACHE) in GitHub Actions with setup-go or actions/cache. Keys, go.sum, and verifying hits.
- Cache Gradle Builds in GitHub ActionsCache Gradle dependencies and build cache in GitHub Actions with gradle/actions/setup-gradle or actions/cache. Keys, read-only PRs, cleanup and checks.
- Cache npm Dependencies in GitHub ActionsCache npm dependencies in GitHub Actions with setup-node or actions/cache. Working YAML, the right key, why not node_modules, and how to confirm a hit.