Docker Layer Caching vs BuildKit Cache Mounts
You want faster container builds in CI and are unsure whether ordering Dockerfile instructions for layer caching is enough, or whether you also need BuildKit cache mounts — this explains what each does, where each falls short, and why production Dockerfiles use both.
When to use each — the short version
- Layer caching always: order instructions so unchanged steps (dependency install) are reused when their inputs have not changed.
- BuildKit cache mounts additionally: keep the package manager’s download cache warm so that when the install layer does rebuild, it does not re-download every package.
Prerequisites
Complete working example
# syntax=docker/dockerfile:1.7
# A build that uses BOTH layer caching and a BuildKit cache mount.
FROM node:20-slim AS build
WORKDIR /app
RUN corepack enable
# --- Layer caching: copy the lockfile FIRST so this layer is reused
# on any source-only change (the install layer's inputs are unchanged). ---
COPY pnpm-lock.yaml package.json ./
# --- BuildKit cache mount: persist pnpm's content-addressable store across builds.
# Even when this RUN layer must rebuild (lockfile changed), packages already
# downloaded are reused from the mount instead of re-fetched from the network. ---
RUN \
pnpm install --frozen-lockfile
# Source changes invalidate from here down, but NOT the install layer above.
COPY . .
RUN \
pnpm build
FROM node:20-slim AS runtime
WORKDIR /app
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]Build it in CI with a registry layer cache so both mechanisms persist across runners:
docker buildx build \
--cache-from type=registry,ref=ghcr.io/acme/app:buildcache \
--cache-to type=registry,ref=ghcr.io/acme/app:buildcache,mode=max \
-t ghcr.io/acme/app:$GIT_SHA --push .Step-by-step walkthrough
Why layer caching alone is not enough. Layer caching reuses the install layer only while its inputs are unchanged — the moment the lockfile changes, that layer and everything after it rebuilds. The rebuild re-runs pnpm install, which without help re-downloads every package from the network, the slowest part of the install. Layer caching is all-or-nothing at the layer boundary; it cannot make a re-run of install cheaper.
What the cache mount adds. --mount=type=cache,target=…/pnpm/store mounts a persistent directory into the RUN step only. It is not part of the resulting image layer — it is scratch space that BuildKit keeps between builds. So when the install layer must rebuild after a lockfile change, pnpm finds most packages already in the mounted store and only downloads the delta. The two mechanisms are complementary: layer caching skips install entirely when it can; the cache mount makes install fast when it cannot be skipped.
Instruction order still matters. The cache mount does not replace ordering. Copying the lockfile before the source (and the source after) is what lets a source-only change reuse the install layer at all — the classic Docker layer caching rule. The mount then optimizes the case ordering cannot help: an actual dependency change.
Registry cache for ephemeral CI. On throwaway CI runners, both the layer cache and the mount would be cold every build without a persistent backend. --cache-to/--cache-from type=registry,mode=max pushes and pulls all layers (including intermediate ones) to a registry, so a fresh runner starts warm — the pattern from Redis-backed and remote CI caching applied to Docker.
Verification
# 1. Source-only change → install layer reused (layer caching).
# Edit a source file, rebuild, and look for "CACHED" on the install RUN step.
docker buildx build --cache-from type=registry,ref=ghcr.io/acme/app:buildcache -t app:test .
# → "=> CACHED [build 4/6] RUN --mount=... pnpm install"
# 2. Lockfile change → install re-runs but is fast (cache mount).
# Add one dependency, rebuild, and confirm install completes in seconds, not minutes,
# because only the new package is downloaded.Expected: an unchanged lockfile keeps the install layer CACHED; a changed lockfile re-runs install but reuses the download store, so it finishes quickly.
Common pitfalls
- Expecting the cache mount to speed unchanged builds. If the install layer is already
CACHED, the mount does nothing — that case is handled by layer caching. The mount only helps when install re-runs. - Omitting
mode=maxon the registry cache. The defaultmode=minexports only final layers, so intermediate layers (like install) are cold on a fresh runner. Usemode=maxto persist them. - Forgetting the
# syntaxdirective. Cache mounts require the BuildKit Dockerfile frontend; without# syntax=docker/dockerfile:1.7(or BuildKit enabled),--mount=type=cacheis not recognized. See the parent guide for BuildKit setup.
Using both together
The two mechanisms compose, and the combination is what makes a container build fast in the two situations that matter: an ordinary source change, and a dependency bump.
On an ordinary source change, the layer cache does the work. The manifests are unchanged, so the install layer is reused wholesale and the build resumes at the source copy. The cache mount contributes nothing because the install never runs, which is exactly right.
On a dependency bump, the layer cache cannot help — the manifests changed, so the install layer genuinely differs and must be rebuilt. This is where the mount earns its place: the package manager finds most of what it needs already downloaded in the mounted store and reconciles the difference in seconds rather than re-fetching the whole tree.
Neither covers the other’s case. Relying on layers alone makes every dependency bump a full cold install. Relying on mounts alone repeats the install on every commit, quickly, but repeatedly.
The ordering rule that both depend on
Both mechanisms are downstream of Dockerfile ordering, and neither compensates for getting it wrong. If COPY . . appears before the install, the install layer’s inputs include every source file, so it is invalidated on every commit and the layer cache has nothing to offer. The mount still helps in that scenario, which is precisely why teams sometimes conclude that layer caching “does not work” — the mount is quietly rescuing a Dockerfile whose ordering makes layer reuse impossible.
Fix the ordering first, measure again, and then decide whether the mount is still needed. On many frontend images it is, because dependency bumps are frequent enough for the cold-install case to matter; on images with a small, stable dependency set it often is not.
Where each cache lives, and why that decides availability
The practical difference on ephemeral CI is location. Layer cache can be exported to and imported from a registry, so a fresh runner can start warm. A cache mount lives on the BuildKit instance’s own disk and does not travel — on a runner created seconds ago it is empty, and stays empty for that job’s lifetime.
That makes the mount a persistent-builder feature more than a CI feature. Teams running a long-lived BuildKit instance, whether self-hosted or via a remote builder service, get its full benefit. Teams on ephemeral runners with no persistent builder get almost none, and should invest the same effort in registry-backed layer cache instead.
Check which mechanism is actually helping before tuning either. A build that is fast because a mount is rescuing a badly ordered Dockerfile looks identical, from the outside, to one that is fast because its layers are being reused — and only the second stays fast when the mount is unavailable.
Related
- Docker Layer Caching for Full-Stack Applications — the parent guide on instruction ordering and layer reuse.
- Reducing Docker Image Size for Frontend Containers — pairing caching with smaller images.
- Securing and Invalidating Build Caches — key correctness that applies to Docker layers too.
- Build Optimization & Caching Strategies — the section overview.