Skip to content
Latchkey LogoLatchkey home

Docker layer caching not working: read the CACHED count first

When Docker layer caching GitHub Actions not working is the complaint, the clock is a bad witness and the build log is a good one: a build that reuses nothing and a build that reuses everything can be seconds apart, and the number that separates them is how many steps printed CACHED. On a runner we measured, the same image came back with 5 cached steps when the cache was wired correctly and 0 in every one of the four ways it can quietly fail.

Cached steps per build: working cache 5, cache-to only 0, empty scope 0, missing registry 0, base image changed 0
One image, one runner, a freshly created builder before every build. Cached step counts and seconds from job-m.sh on a latchkey-small runner, 2026-09-20.

A layer cache that is configured and not working produces a build that looks entirely normal. Nothing fails, the workflow is green, and the only evidence is that every step ran again. So this is diagnosed by counting rather than by feel.

So the method below is one reading followed by five causes. Four of them we reproduced on a runner; the third is the branch rule, which is GitHub's and not Docker's, and is quoted from documentation rather than measured. Every build here ran on a builder created fresh immediately before it, so the declared cache was the only cache in play.

The reading: count the CACHED steps

Build with plain progress output and count the steps that printed CACHED. That is the whole diagnostic, and it takes one run. A working cache on our three-stage image printed 5; every broken configuration printed 0, including ones that finished barely a second slower than the working build.

The other line worth finding is the import. When a backend is reachable, BuildKit prints that it is importing a cache manifest before it starts. If that line is missing, nothing was read, and no amount of cache-to will change it.

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

What the numbers looked like

Here is the same image, built nine times on one runner, with the eight builds worth comparing in the table. Read the cached column first and the seconds second, because the seconds are close together and the step counts are not. The working cache saved 1.43 seconds off a 10.56 second cold build, which is a smaller gap than most people expect and still the difference between reusing the install and repeating it.

The row that matters most is the second: writing a cache on every run while reading none costs time rather than saving it. That build took 13.43 seconds, nearly three seconds longer than doing nothing at all, because exporting is work.

Build, on a freshly created builderCached stepsSeconds
Cold, no cache backend declared010.56 s
cache-to only, no cache-from013.43 s
cache-from an empty local cache010.62 s
cache-from a registry ref that does not exist010.35 s
Warm cache, base image changed010.52 s
Warm cache, lockfile changed110.86 s
Warm cache, source changed59.13 s
Warm cache, source changed, second pass59.28 s

Cause 1: the cache is written and never read

Both halves are required and they are separate settings, so it is easy to end up with one. cache-to writes; cache-from reads. A workflow with only the first writes a fresh cache every run, reads none of it, and pays the export cost forever: 13.43 seconds against 10.56 for no cache at all.

The signature is a build with 0 cached steps that is slower than a plain build, and a cache store that keeps growing. Add the other half, pointed at the same place.

.github/workflows/ci.yml
- uses: docker/build-push-action@v7
  with:
    context: .
    cache-from: type=gha
    cache-to: type=gha,mode=max

Cause 2: it is reading a scope nothing wrote

A cache-from that points somewhere empty does not fail. We pointed one at an empty local directory and the build ran to completion with 0 cached steps and not one line about the cache in its output, among plenty of unrelated warnings from npm and from buildx. We pointed another at a registry reference that does not exist, and that one did print an error, "failed to configure registry cache importer", and then built successfully anyway. Neither turned the workflow red.

On type=gha the same mistake wears a different hat. Docker documents the scope as a key that "By default, it is set to buildkit", and where several builds share a scope "each build will overwrite the cache of the previous, leaving only the final cache". Two images built in one workflow with the default scope take turns destroying each other, and both of them look like a cache that stopped working.

.github/workflows/ci.yml
- uses: docker/build-push-action@v7
  with:
    context: .
    cache-from: type=gha,scope=api
    cache-to: type=gha,scope=api,mode=max

Cause 3: the branch cannot see the cache, per the docs

This is the one cause here we did not reproduce, and it is not a Docker setting: it applies only to type=gha, which stores its blobs in the Actions cache and inherits its rules. A run can restore caches from its own branch and from the default branch, and a pull request can also reach the base branch, but never a sibling or a child branch.

So the first build on a new feature branch is cold unless the default branch has written a cache it can inherit. That is documented behavior, not a fault. Export on pushes to the default branch and let pull requests read what those runs wrote.

.github/workflows/ci.yml
on:
  push:
    branches: [main]      # writes the cache every other run inherits
  pull_request:           # reads it

Cause 4: mode=min kept the part you needed

On a multi-stage build, mode=min exports only the layers that reached the final image, and the installing stages are usually not among them. The cache looks present, the step count is above zero, and the expensive stage rebuilds anyway.

We measured that pair separately on a four-stage version of the same image: mode=max restored in 9.7 seconds and stored 98 MB, mode=min restored in 11.3 and stored 70. Writing 28 MB less bought a slower build. If your Dockerfile has more than one stage, you want mode=max.

Cause 5: the base image moved under you

BuildKit checks the base image first and compares each later instruction against the cached layers, so a different FROM invalidates the entire build. We changed one digit, node:20-slim to node:22-slim, with a warm cache in place, and the count went from 5 cached steps to 0 and the build back to 10.52 seconds, which is cold.

A floating tag does this without a commit. node:20-slim is rebuilt upstream regularly, and the day it moves every job rebuilds from scratch with nothing changed in your repository. Pin the digest if cold builds are expensive enough to care about, and accept that you now own the upgrade.

Dockerfile
# a tag that moves when upstream rebuilds
FROM node:20-slim

# a base that cannot move without a commit
FROM node:20-slim@sha256:<digest>

What we ran, so you can disagree with it

One script, job-m.sh, run once on a Latchkey latchkey-small runner on 20 September 2026, committed under content/repro/timings/docker-layer-caching-not-working/ with its unedited job-m.log and a status file naming the job id, the runner size and the exit code. The log carries the plain progress output of every build, including the CACHED lines counted here.

What it does not cover. type=gha was never exercised: the log records ACTIONS_CACHE_URL unset outside a GitHub-hosted job, so the scope and branch rules here are quoted from documentation read the same day. The backend used was type=local, with no network between builder and store, so the seconds are a floor. One pass per row means they carry noise: the step counts are the finding, not the tenths.

Frequently asked questions

How do I check whether buildx is reading my cache?
Build with --progress=plain, count the steps that printed CACHED, and look for the line where BuildKit says it is importing a cache manifest. On our runner a working cache printed 5 cached steps and that import line; every broken configuration printed 0 and no import, while finishing within about a second of a plain build.
Why does my build still take the same time with cache-from set?
Most often because nothing was ever written where it is reading, or because only cache-to was configured somewhere else and the two point at different scopes. A cache-from that finds nothing is not an error: our build against an empty cache directory ran to completion without one line about the cache in its output, and the one against a missing registry printed an importer error and then succeeded anyway.
Does a new base image rebuild every layer below it?
Yes. BuildKit checks the base image first and invalidates everything after a layer that does not match, so a different FROM rebuilds the lot. We changed node:20-slim to node:22-slim with a warm cache and went from 5 cached steps to 0. A floating tag can do this with no commit of yours.
What does "failed to compute cache key" mean?
It is a different problem from a cache miss: BuildKit could not resolve an input it needs to hash, almost always a COPY or ADD of a path that does not exist in the build context. Check the path against your .dockerignore and against the context directory you actually passed, because a file excluded from the context is missing as far as the builder is concerned.

Related guides

References

Most zero-CACHED builds are wiring, not Docker. On Latchkey it is one step for both halves. Start free → 30-day trial · No credit card