skip to content

What does a BuildKit cache mount (RUN --mount=type=cache) do, how does it differ from Docker's normal layer caching, and when would you reach for it?

level: middleimportance: should knowfreq 45%

answer

  1. layer cache = all-or-nothing per instruction
  2. cache mount = persistent dir for that RUN, never in a layer
  3. helps exactly when the layer cache misses
  4. sharing=locked for apt and other non-concurrent stores
  5. builder-local: ephemeral CI runners start cold; prune or disks fill

basics

~20 s

A cache mount gives a RUN instruction a persistent directory that lives on the builder across builds and is not part of any layer. Layer caching reuses whole instruction results and is invalidated by any input change; a cache mount still helps after invalidation, because the package manager's downloads or compiler outputs survive. Use it for dependency and compiler caches.

solid answer

~60 s

**Layer cache** works at instruction granularity: if the inputs to a step are unchanged, BuildKit reuses the resulting layer wholesale. Change one dependency in a lockfile and that step, plus everything after it, re-runs from scratch. **Cache mount** attaches a persistent, builder-local directory to a single RUN, at a path of your choice, and that directory is not committed into the image: ``` RUN --mount=type=cache,target=/root/.m2 mvn -q package RUN --mount=type=cache,target=/var/cache/apt,sharing=locked apt-get update && apt-get install -y ... ``` So when the layer cache does miss, the step re-runs but the package manager finds most artefacts already downloaded, turning a full re-resolve into an incremental one. They are complementary: keep dependency manifests copied before source so the layer cache still helps, and add cache mounts so the miss case is cheap. Caveats: the cache lives on the builder, so ephemeral CI runners get nothing unless you use a persistent or remote builder; concurrent builds need `sharing=locked` for tools that are not concurrency-safe; and it is not part of the image, so nothing from it can be shipped.

code

dockerfile · 7 lines
dockerfile
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

go deeper

for a junior

Know that a cache mount is a persistent build-time directory for things like npm or Maven downloads, and that it is not part of the image.

for a middle

Contrast it precisely with layer caching, show the syntax with a real target path, and mention sharing modes and the apt docker-clean gotcha.

for a senior

Reason about builder lifetime in CI, cache growth and pruning, hermeticity for release builds, and combining mounts with exported registry cache.

for a principal

Decide the build cache architecture: shared remote builders versus per-runner, storage budgets and eviction, and where reproducibility requires cold builds.

## Two different caches The **layer cache** is what people usually mean by "Docker cache". For each instruction BuildKit computes a cache key from its inputs (the parent state, the command string, the content of copied files) and, on a hit, reuses the previously produced filesystem result without executing anything. This is why Dockerfile ordering matters so much: put things that rarely change high up, and copy dependency manifests before application source, so that editing one source file does not invalidate dependency installation. The layer cache is all-or-nothing per instruction. A single character change in package-lock.json invalidates npm ci and every subsequent step. The step then runs in a container whose filesystem starts from the parent layer, which contains no npm cache, so every package is downloaded again from the network. The **cache mount** attacks exactly that miss case. RUN --mount=type=cache,target=<path> asks BuildKit for a persistent directory managed by the builder and mounts it at that path for the duration of the instruction. Contents survive across builds and across cache-key invalidation, and are never committed to a layer, so they add nothing to image size. ## Typical targets - Maven: /root/.m2 - Gradle: /root/.gradle (the build-tool-integrated imaging plugins themselves are a separate topic) - npm: /root/.npm; pnpm: /pnpm/store - Go: /root/.cache/go-build and /go/pkg/mod - Rust: /usr/local/cargo/registry and target - pip: /root/.cache/pip - apt: /var/cache/apt and /var/lib/apt/lists, which additionally requires removing the docker-clean apt config that deletes downloads ## Options that matter **id** names the cache; distinct ids give distinct caches, which is how you avoid two unrelated projects sharing one directory. Default id is the target path. **sharing** controls concurrent access: shared (default, concurrent builds use it simultaneously), locked (serialise, second build waits) and private (each concurrent build gets its own). Package managers with lockfile-based stores such as apt or cargo generally want locked; content-addressed stores that tolerate concurrency are fine shared. **from**, **source**, **mode**, **uid**, **gid** let you seed a cache from a stage or image and control permissions, which matters when the RUN executes as a non-root USER and cannot write into a root-owned cache. ## Limits and failure modes The cache is **local to the builder instance**. On a developer laptop that is exactly what you want. In CI with ephemeral runners, a fresh builder starts with an empty cache mount and you get nothing. Options: use a persistent self-hosted runner, run a long-lived buildx builder (for example a remote or Kubernetes driver), or accept the loss and rely on registry-exported layer cache for the parts that layer cache can cover. Cache mounts, unlike layers, are not exported by --cache-to to a registry, which surprises people. Caches also **grow unbounded**. docker builder prune, including filters on cache mount usage, is needed on long-lived builders, otherwise disks fill. Because the mount is not part of the image, **artefacts you need in the image must be written elsewhere**. A common bug is building into a cache-mounted target directory (Rust, Gradle) and then finding nothing to COPY afterwards, because the output lived in the mount. The fix is to copy the produced binary out to a normal path inside the same RUN. Finally, cache mounts weaken hermeticity: a build can now succeed because of state on the builder that a clean machine does not have. For release builds, teams often build with a cold builder or explicitly disable mounts to prove reproducibility, and keep mounts for the fast inner loop and CI throughput. ## The combined pattern Order the Dockerfile so manifests are copied before source (layer cache does the heavy lifting on the common path), and add cache mounts so the invalidation path is incremental rather than a cold download. Together they turn a dependency bump from a five-minute rebuild into a few seconds.

  • Your CI uses ephemeral runners and cache mounts appear to do nothing. Why, and what are the options?
    A cache mount lives on the builder instance, so a runner that starts a fresh builder every job always begins with an empty cache, and cache mounts are not exported by --cache-to to a registry. The options are to keep a long-lived builder (self-hosted runner, a remote or Kubernetes buildx driver shared by jobs), or to lean on registry-exported layer cache with --cache-from/--cache-to for the parts layer caching can cover. Some teams also snapshot the cache directory into a CI cache action, but that reintroduces upload and download cost.
  • After adding a cache mount at the Rust target directory, the final COPY --from finds no binary. What went wrong?
    The build wrote its output into the cache mount, and that mount is not part of the stage's filesystem once the instruction ends, so there is nothing at that path to copy. The fix is to copy the artefact out to an ordinary path inside the same RUN that has the mount, for example cp target/release/app /out/app, and then COPY from /out.

The layer cache is keeping the finished dish in the fridge and reheating it if nothing changed; a cache mount is keeping the pantry stocked, so even when you must cook again you are not driving to the shop for every ingredient.

saying these in an interview costs you the question

  • Thinking a cache mount replaces good instruction ordering
  • Believing cache mount contents are pushed with the image or exported by --cache-to
  • Using a shared cache for apt or other non-concurrency-safe stores and hitting corrupted state
  • Leaving build output inside the mount and finding nothing to copy afterwards
  • Never pruning, then blaming BuildKit when the builder disk fills

context