Docker layer caching in GitHub Actions: which backend, and what it returns
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.
- 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=maxWhat 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 |
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.
- 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=maxOrder 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.
# 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
build --remote_cache=grpcs://cache.example.com
build --remote_upload_local_results=trueWhat 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.
- uses: latchkey-dev/docker-cache-action@v1
with:
context: .
tags: app:ciCheck 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.
- run: |
docker buildx build --progress=plain . 2>&1 | grep -E "CACHED|DONE" | head -20Frequently asked questions
How do I use Docker layer caching in GitHub Actions?
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?
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?
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.Related guides
References
- Docker Docs: cache storage backends (verified 2026-09-20)
- Docker Docs: optimize cache usage in builds (verified 2026-09-20)
- GitHub Docs: dependency caching, limits and eviction (verified 2026-09-20)
- Latchkey documentation: Docker layer caching (verified 2026-09-20)
- Docker documentation
- Docker build cache
- GitHub Actions documentation