# Docker layer caching in GitHub Actions: which backend, and what it returns

> Docker layer caching GitHub Actions runners can reuse has to live outside the job. Here is what type=gha, registry and local each returned.

Source: https://latchkey.dev/learn/speed/docker-layer-caching-in-github-actions  
Updated: 2026-09-20

Docker layer caching GitHub Actions runners can reuse does not come for free: every job starts on a fresh runner with an empty image store, so every `FROM` and every `RUN` executes again. You get it back by telling buildx to export the layer cache somewhere that outlives the runner, and on the image we measured that was worth 3.4 seconds of a 12.2 second build.

Each instruction in a Dockerfile produces a layer, and BuildKit reuses a layer when the instruction and its inputs hash the same as last time. Change one thing and that layer plus every layer after it is rebuilt. The cache is invalidated downward, never selectively, which is why ordering decides how much of it survives a commit.

On your machine none of this needs configuring, because the daemon keeps the layers between builds. In CI the builder is new every time. The only question worth answering is where the layers are going to live between runs, and what that choice costs you.

## The minimal setup, and what each line is doing

Three lines turn it on. `setup-buildx-action` gives you a BuildKit builder that supports external cache; `cache-from` tells it where to look for layers before building; `cache-to` tells it where to write them afterwards. Without both halves you have half a cache: a build that reads nothing, or one that writes a cache nothing ever reads.

```.github/workflows/ci.yml
- uses: docker/setup-buildx-action@v4

- uses: docker/build-push-action@v7
  with:
    context: .
    push: false
    tags: app:ci
    cache-from: type=gha
    cache-to: type=gha,mode=max
```

> `mode=max` exports the layers of intermediate stages as well as the ones in the final image. On a multi-stage build that is the difference between caching your dependency install and caching only what you shipped.

## What each backend returned

We built the same four-stage Node image against each backend that works outside a GitHub-hosted job, then changed one source file and rebuilt on a freshly created builder so the declared backend was the only cache in play. A cold build with no backend at all took 12.2 seconds and is the number the rest are measured against.

`type=registry` with `mode=max` was fastest to restore, at 8.8 seconds, and repeated at 8.7 on a second pass. `type=local` with `mode=max` came in at 9.7. `mode=min` was the odd one out: it stores 28 MB less and restores more slowly, at 11.3 seconds, because the intermediate stages it declined to export are exactly the ones doing the expensive work.

| Backend | Rebuild after a source change | Cache stored | Export cost |
| --- | --- | --- | --- |
| No cache backend | 12.2 s | nothing | n/a |
| `type=local,mode=min` | 11.3 s | 70 MB | 12.9 s |
| `type=local,mode=max` | 9.7 s | 98 MB | 16.6 s |
| `type=registry,mode=max` | 8.8 s | 99 MB in the registry | 15.7 s |

> Measured by `content/repro/timings/docker-layer-caching-in-github-actions/job-b.sh` on a Latchkey `latchkey-small` runner on 2026-09-20, Docker 29.7.2 and buildx 0.36.1, one pass per row. The script, its `job-b.log` and the job record are committed together. That script ran twice: the first attempt aborted at its first `docker buildx rm` because the runner executes the command under `set -e` and no builder existed yet, and the committed version differs from it by one added line, `set +e`. No timing carried over. The registry was a `registry:2` container on the runner itself, so these numbers exclude the network an off-host registry would add.

## Which backend to pick

The choice is mostly about where the bytes are allowed to live. `type=gha` is the least work and stores the cache inside the Actions cache for the repository, which means it competes for the same 10 GB cap as every dependency cache you keep. `type=registry` puts the cache in a registry you already push to, costs registry storage, and is the one that scales past a small image. `type=local` writes to the runner's disk and is useful only when that disk survives the job.

We could not measure `type=gha` here and would rather say so than estimate it. Our script checked and printed the environment: `ACTIONS_CACHE_URL` is unset outside a GitHub-hosted job, so the backend has nothing to talk to, and Docker still labels it Beta in its own documentation. What is documented and not in dispute is where it stores the bytes, and that is the part that decides whether it is right for you.

| Backend | Stored in | Best for | The catch |
| --- | --- | --- | --- |
| `type=gha` | The Actions cache for the repository | Small and medium images, least setup | Shares the 10 GB repository cap with every other cache |
| `type=registry` | A container registry | Large images, sharing a cache across repositories | Registry storage and credentials |
| `type=local` | A directory on the runner | A runner with a disk that persists | Nothing survives on an ephemeral runner |
| `type=inline` | The pushed image itself | Single-stage builds you already push | Final stage only, so `mode=max` is not available |

## The 10 GB interaction nobody plans for

The Actions cache is 10 GB per repository by default and `type=gha` spends from that same budget. A `mode=max` layer cache for one substantial image can take most of it, and when the repository crosses the cap GitHub evicts the least recently used entries to make room. What you see is not a Docker problem: it is your npm or Gradle cache quietly missing in a job that has nothing to do with Docker.

If your image cache is large, move it to a registry and leave the Actions cache to dependencies. That one change removes the contention entirely, and it is the reason the registry backend is worth its extra setup on anything past a toy image.

```.github/workflows/ci.yml
- uses: docker/login-action@v4
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@v7
  with:
    context: .
    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
```

> The job needs `permissions: packages: write` for the cache image to be pushed to ghcr.io.

## Order the Dockerfile, or the backend cannot help you

No cache backend rescues a Dockerfile that invalidates its first layer on every commit. Copy the dependency manifests and install before you copy the source, so a code change cannot reach back and bust the install layer. This matters more than which backend you chose: a cache-hostile Dockerfile with a perfect backend still rebuilds nearly everything, every time.

```Dockerfile
# cache-hostile: any source change reinstalls everything
COPY . .
RUN npm ci

# cache-friendly: the install layer survives a source change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
```

## A remote build cache is a different thing

Bazel, Gradle, Turborepo and Nx all have remote caches of their own, and they are not competing with the Docker layer cache: they sit inside the build step, keyed on task inputs, sharing results between runners and developer machines. A Docker layer cache skips an instruction; a remote build cache skips a compilation.

They stack. If your image build runs Gradle inside a `RUN` instruction, the layer cache decides whether that instruction re-runs at all, and the remote build cache decides how much work it does when it does re-run. Warm the remote cache from the default branch, or pull requests only benefit after they have already paid for it once.

```.bazelrc
# .bazelrc
build --remote_cache=grpcs://cache.example.com
build --remote_upload_local_results=true
```

## What a runner with its own layer cache changes

Latchkey takes a different route to the same place. Its `latchkey-dev/docker-cache-action@v1` wraps `docker buildx build` with registry-backed caching against a private registry the platform manages for your organization, so the wiring, the credentials and the registry are not yours to run. The documented effect is the one above: "repeat image builds reuse every unchanged layer across ephemeral runners".

Read it as one more answer to the same question, which is where the layers live between jobs. The documentation does not publish a size limit or an eviction policy for that registry, so if those matter to your planning they are worth asking about rather than assuming.

```.github/workflows/ci.yml
- uses: latchkey-dev/docker-cache-action@v1
  with:
    context: .
    tags: app:ci
```

## Check that it is actually hitting

A cache that is configured and not working looks exactly like no cache, so verify it rather than assuming. Build with plain progress output and count the `CACHED` lines: the install layer should be one of them on any run that did not change the lockfile. If every layer rebuilds, `cache-from` is not matching, and the usual reasons are a ref that does not exist yet, a first run that has not written one, or a builder created without the external cache support.

```.github/workflows/ci.yml
- run: |
    docker buildx build --progress=plain . 2>&1 | grep -E "CACHED|DONE" | head -20
```

## FAQ

### How do I use Docker layer caching in GitHub Actions?

Add `docker/setup-buildx-action` to get a builder that supports external cache, then give `docker/build-push-action` both `cache-from` and `cache-to`. Use `type=gha` to start, and move to `type=registry` when the image is large enough to crowd the 10 GB Actions cache. Both halves are required: `cache-to` alone writes a cache nothing reads.

### What is the difference between mode=min and mode=max?

`mode=min` exports only the layers that ended up in the final image; `mode=max` exports the intermediate stages too. On our four-stage image, `mode=max` stored 28 MB more and restored in 9.7 seconds against 11.3 for `mode=min`, because the stages `mode=min` skipped were the ones doing the installing. On a single-stage build the difference is close to nothing.

### Should I use type=gha or type=registry?

Use `type=gha` for small and medium images where you will not approach the 10 GB repository cap, because it needs no credentials and no storage of your own. Use `type=registry` when the image is large, when several repositories should share one cache, or when a Docker layer cache is evicting your dependency caches.

### How do I cache Docker images, rather than layers, in GitHub Actions?

Do not save and restore image tarballs through `actions/cache`: it stores and rehydrates the whole image every run and is usually slower than rebuilding with a layer cache. If the goal is to avoid pulling a base image repeatedly, a registry mirror or a pull-through cache is the tool, and the layer cache handles everything you build yourself.

## References

- [Docker Docs: cache storage backends (verified 2026-09-20)](https://docs.docker.com/build/cache/backends/)
- [Docker Docs: optimize cache usage in builds (verified 2026-09-20)](https://docs.docker.com/build/cache/optimize/)
- [GitHub Docs: dependency caching, limits and eviction (verified 2026-09-20)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [Latchkey documentation: Docker layer caching (verified 2026-09-20)](https://latchkey.dev/documentation/docker-layer-caching)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
