Blacksmith Sandboxes Coming soon
Jul 31, 2026
 ]

The physics of Docker build caching

Concepts, rules of thumb, and what dissecting a few hundred CI builds says about them.
Piotr Bejda
Member of Technical Staff
TL;DR
Concepts, rules of thumb, and what dissecting a few hundred CI builds says about them.
Get started!
Try us Free

Advice about Docker build caching tends to arrive as folklore: put the COPY late, enable some cache action, hope. The trouble is that "Docker caching" is not one cache: it's at least three, with different lifetimes, different invalidation rules, and different failure modes. Which of them matters, and whether the folklore helps, does nothing, or actively hurts, depends on the shape of the build in front of you.

There are too many build shapes to cover one by one: every stack, monorepo layout, and orchestration has its own wrinkle. So this post aims for a different kind of completeness: the concepts and rules of thumb that decide the outcome in any scenario, each illustrated with a measurement from a benchmark lab we built. The lab spans 18 scenarios across Go, Python, Rust, Node, Java, and Bazel, run on real CI runners, comparing every commonly used caching backend. Where our own product wins, I'll show it; where something is genuinely missing, ours included, I'll say so.

The mental model: three caches, one invalidation wave

The concepts here stand on their own, so no Dockerfile expertise is required to follow along. When the examples reference specific instructions (FROM, COPY, RUN, multi-stage builds), Docker's Dockerfile overview and building best practices are good prose introductions to lean on.

A Docker build is a chain of instructions, each producing a layer keyed by the instruction and the content that feeds it. A change invalidates its layer and every layer after it, a wave that propagates to the end of the chain. That global reach is what makes the wave worth optimizing for: how far it travels decides how much of the build re-runs, on every single change.

(Strictly, the build graph is a DAG: with multi-stage builds each stage is its own chain, joined by COPY --from edges, and the wave only reaches the stages that depend on the change. But within a stage it's always a chain, most Dockerfiles are effectively a single chain, and that's what the examples here show; everything said about the wave applies per-chain.)

The chain is only the first cache. Two more sit beside it:

  • Mount caches (RUN --mount=type=cache): directories that persist across builds. They don't stop the wave, they cushion it: when the wave re-runs an instruction, the instruction finds its mount still warm, so a re-run isn't a from-scratch run. This is where package-manager and compiler state lives (GOCACHE, cargo's registry and target/, pip's wheels, pnpm's store).
  • The image store (/var/lib/docker): pulled base images, built images, everything the daemon itself caches. It touches the chain at exactly one point, the FROM layer: when a base image changes, the wave starts at the very top, and whether the new base is a fast local read or a slow registry pull depends on this store. The wave never invalidates anything in it. And it lives well beyond builds: docker run, service containers, and image distribution are all reads against this store.

A question that will come back for each of these caches, in every setup below: where does it live, and does it survive to the next CI job? Everything that follows is a zoom-in on some part of this picture.

One more piece of shared vocabulary: the change classes from the diagram above are also how every measurement in this post is structured. Each scenario builds four times, and each build probes one class:

  • cold: the first build, nothing cached anywhere.
  • warm: rebuild with nothing changed; every layer should come from cache.
  • source-change: a code edit, dependency manifests untouched; the wave starts below the install step.
  • dep-change: a manifest/lockfile bump; the install step and everything after it re-runs.

Rule of thumb #1: think in change classes, not in builds. Your median CI build is a source-change, not a cold build. A cache setup that looks great on the "second build of the same commit" demo and terrible on a one-line code edit is optimizing the phase you hit least.

Order layers by change frequency

The general guideline: figure out which of your steps are expensive and stable, and order the Dockerfile so that frequently changing content sits below them. The most common instance is manifest-first ordering: copy your dependency manifest first, install dependencies, and only then copy the source. The expensive install layer then invalidates only when the manifest changes, not on every commit.

We measured the same service both ways (COPY . . before the install vs manifest-first) across five stacks. Source-change build times:

stack manifest-first COPY . . first gap
Go 1.1s 5.7s ~5x
Python (numpy/pandas/scipy) 0.9s 22.7s ~24x
Node (express) 0.8s 2.6s ~3x
Rust (axum) 2.1s 10.3s ~5x
Java (Spring Boot) 3.2s 15.9s ~5x

Two things worth noticing. First, cold time and the final image are identical in both variants; this optimization is free. Second, the gap is exactly the cost of your dependency step: cheap npm ci gives 3x, Python native wheels give 24x, and a 1GiB model file copied in the wrong layer turns every source edit into ~23s of pure I/O (we measured that too).

Rule of thumb #2: the payoff of layer ordering equals the cost of the step it protects. Know your most expensive stable step, and make sure nothing that changes often sits above it.

Mount caches are the other half, and they don't travel

Layer caching is all-or-nothing: when the wave hits the install step, the whole step re-runs. Mount caches are what make the re-run cheap: the package manager finds its downloads, the compiler finds its incremental state. For compiled languages this is routinely the difference between "re-run means recompile the world" and "re-run means recompile one file".

Here's the catch that explains a large fraction of "we enabled caching and it's still slow" complaints. The very thing that lets a cache mount survive the wave (being a directory on the builder's local disk, outside the layer filesystem) also puts it outside what cache exporters handle. A cache-to export (type=gha, type=registry, type=inline) is a bundle of layer blobs: it serializes each instruction's resulting layer so another builder can skip re-running it. Mount contents are not part of any layer, so they are never in the bundle.

The two mechanisms attack different parts of the problem. An exported layer cache works along the wave: it lets a fresh machine skip every step the wave didn't reach, and it's the only mechanism that travels between machines. What it cannot do is help the step the wave did reach; making that re-run cheap is the mount cache's job. So on a fresh runner with an exported cache, the surviving layers are warm but every --mount=type=cache directory starts empty: the one step you needed to be fast runs from scratch.

The Rust scenario makes it concrete: a dependency change costs 3.6s when the mounts survive (persistent builder state), 164s with the GHA cache backend, and 19s with no cache at all. Read that again: the export backend was 8x slower than doing nothing, because it paid to import and export layers that the change invalidated anyway, and the state that would have helped wasn't in the export.

Rule of thumb #3: know where your build's win actually lives. If it lives in mount caches (compiled languages, Bazel, anything incremental), a layer-export backend cannot deliver it, no matter how it's tuned.

The backend landscape

With the model in place, the backends sort themselves into two families:

  • Export/import: serialize (some of) the cache to an external store after the build, restore it before the next one: gha, registry, inline, plus workarounds like buildkit-cache-dance, which patches the mount-cache gap by copying --mount=type=cache contents out of the builder after the build, storing them in the Actions cache, and injecting them back before the next one. Portable, works on any runner; pays serialization both ways, drops mount state (except cache-dance, which pays extra to keep it).
  • Persistent builder state: don't move the cache at all: keep BuildKit's disk between jobs and reattach it. This is what Blacksmith's sticky disks do: the runner mounts the same builder state (layers and mounts) on every job, as a copy-on-write clone of the last committed snapshot.

The matrix below is the four-phase sequence across backends, for four of the lab's stacks:

The pattern is consistent. On the phases that dominate real CI traffic (warm, source-change, dep-change), persistent state wins by 10–30x, mostly because of the mount caches. Export backends cluster in an uncomfortable middle: often slower than no cache on change phases, because import/export overhead scales with cache size while the hit rate scales with what the change didn't touch.

Being honest about the other direction:

  • True cold builds don't care. A first build on a brand-new cache key is a from-scratch build everywhere (83s sticky vs 85s no-cache on the Go scenario). Persistent disks don't seed a new key from a sibling, a place where export caches, which any branch can restore, genuinely have an answer we lack.
  • Export caches are portable. They work on hosted runners, across providers, in air-gapped mirrors. A persistent disk is a property of the platform you run on.
  • The persistence tax is real but flat. Attaching the disk costs 2–4s, committing it 1–2s, and this held regardless of state size in our runs (even a commit after populating ~1GiB of fresh state took 2s, because it's a block-level snapshot, not an upload).

Rule of thumb #4: export backends charge a transfer tax on every build; only use one when the hits are worth more than the tax. The import and export happen whether or not the build hits, so the math is simple: an export backend makes sense when the win lives in layers and the cache is small. When the win lives in mount caches, or builds run often enough that 20–90 seconds of import/export per job adds up, keep the cache in place between jobs instead.

Scope the cache to what shares content

Persistent state introduces a question exports never face: who shares the disk? The default temptation is one cache per repo. The right answer is one cache per image lineage: a set of builds that actually reuse each other's layers.

We measured both failure directions:

  • Too broad: 8–12 unrelated images building in parallel on one shared key. Each build's commit evicts the others' state; round-two hit ratios collapse to 0.17–0.20 and single rebuilds hit 133s, worse than not caching. Per-image keys on the same fleet: 0.73–0.76 hit ratio, ~3s rebuilds.
  • Too narrow: a monorepo where three services inherit one base stage, split across per-service keys. Every key rebuilds and stores its own copy of the shared base (3x the footprint at 3 services, 2.4x at 8, growing linearly), and a shared-base change rebuilds N times instead of once.

Rule of thumb #5: share the cache exactly as far as the layers are shared. Related images (a monorepo tree with a common base): one key. Unrelated images that happen to live together: one key each. Repo topology is a proxy, and often a wrong one.

Caches grow by what they've seen, not how often you build

The lifecycle question: does a long-lived cache rot? We ran 25-cycle churn tests (a realistic 70/30 mix of source edits and dependency bumps on the same key) and tracked what actually grows:

Layer records grow slowly and linearly (BuildKit's size-capped GC handles them); mount caches converge on the union of dependency versions the cache has ever seen, then go flat. Bounded inputs, bounded cache.

The honest part is what doesn't get cleaned up. To BuildKit, a mount cache is a single record: an active repo touches it every build, so it never goes idle, and nothing trims the dead content inside it. Whether garbage accumulates depends entirely on the toolchain using the mount: Go's build cache self-trims (entries unused ~5 days), while Cargo's target/, the pip/npm/pnpm caches, and Bazel's disk cache all grow until something external prunes them. No platform (ours included) currently gives you visibility inside a mount cache; today the honest answer is to know your toolchain's behavior and occasionally reset keys for Cargo-like ecosystems with heavy version churn.

Rule of thumb #6: caches grow with distinct dependency versions seen, not with build count.

The other image store: containers, services, and fan-out

Everything so far was builder state. The second half of Docker's disk life is the image store, /var/lib/docker, and in CI it gets rebuilt from nothing constantly: every job re-pulls the same base images, the same services: sidecars (that postgres container, pulled on every single run), the same toolchain images.

Blacksmith's answer here is a different product with deliberately different scoping: container caching gives the whole org one shared image-store disk. Every job's VM boots with /var/lib/docker mounted from a copy-on-write clone of it; commits happen only when a job's image set actually changes. Org-wide sharing is correct here for exactly the reason it was wrong for build caches: public base images are identical across repos, build state isn't. It's also zero configuration: no action, no workflow change.

The most interesting consequence is fan-out: the build-once-run-everywhere pattern where one job builds an image and N jobs run it. The traditional path is builder → registry push → N pulls, and it pays image-size-scaled serialization at every arrow. We measured it with a deliberately fat 3.34GB image and three parallel consumers:

Once the image is in the org cache, a consumer's "pull" is a manifest check (~0.9s vs ~129s, 140x), and running the image reads 23MB off the clone: 0.7% of its bytes, streamed lazily as the container touches them. The per-consumer cost stopped scaling with image size.

The honest limit: this kicks in from the second wave onward. The first consumers after a genuinely new image still pull from the registry; the cache commits after somebody has the image, and there's no primitive yet for "hand this just-built image directly to the jobs behind me" without the initial registry round-trip. Related trap: an image rebuilt from identical sources gets new layer digests (COPY records mtimes), which quietly defeats any store-level dedup; reproducible tags matter.

There's also a producer trap worth calling out, because it inverts the win. A shared image-store disk is built for consumers: jobs that pull a stable set of images. A job that builds a multi-GB, uniquely-tagged image with load: true (or --load) is the opposite: the image is exported into /var/lib/docker and re-read for the push, and the unique tag forces a fresh multi-GB commit of the shared disk on every run. The fix is to skip the daemon entirely: push: true pushes straight from the builder to the registry. (Inspect the pushed image with docker buildx imagetools inspect instead of a local docker inspect.)

Rule of thumb #7: distribution is a caching problem too, and the win isn't a closer registry, it's not serializing at all.

The cheat sheet

your situation what's costing you what fixes it
every commit reinstalls deps COPY . . above the install manifest-first ordering (free, 3–24x)
compiled language, cached but still slow mount caches dropped by export backend persistent builder state
“cache enabled”, change builds slower than cold import/export tax > hit value smaller cache, or stop exporting
many unrelated images, one cache eviction thrash (hit ratio ≤ 0.2) one key per image lineage
monorepo, per-service keys shared base duplicated N times one shared key for the tree
cargo/pip cache grows forever no toolchain GC inside the mount know your toolchain; reset keys occasionally
N jobs pull the same fat image registry serialization × N org-wide image-store cache
build job slower after enabling image-store cache load: true double-handles the image through the cached daemon push: true straight from the builder
first build on a new key is slow nothing seeds a fresh cache (nothing does; this one's real)

And the rules of thumb, in one screen:

  1. Think in change classes, not builds; optimize the source-change, not the demo.
  2. The payoff of layer ordering equals the cost of the step it protects.
  3. Know where your build's win lives (layers or mounts) before picking how to persist it.
  4. Export backends charge a transfer tax on every build; only use one when the hits are worth more than the tax.
  5. Share a cache exactly as far as the layers are shared.
  6. Caches grow with distinct dependency versions seen, not with build count.
  7. Distribution is caching too: the win is not serializing at all.

None of this makes a badly ordered Dockerfile fast, and nothing makes a true first build cheap. But between those two poles, nearly all of the 10–100x that CI teams leave on the table comes down to these seven, and the three-cache picture that generates them.

World globe