Speeding Up Local Image Builds with BuildKit Cache Mounts
Every time you edit package.json, requirements.txt, or go.mod, the next local build sits on RUN npm ci or RUN pip install for minutes while it re-downloads packages it already fetched an hour ago. This page removes that repeated download cost with RUN --mount=type=cache, a BuildKit feature that persists a package manager's download directory on the build host across builds — independently of the image layer cache. It sits under Docker build cache optimization within the broader set of containerized local environment patterns, and complements the layer-ordering work covered in optimizing Docker Compose for fast local rebuilds.
Diagnostic
The tell-tale symptom is a build whose slow step is a download, not a compile: the terminal prints a progress bar of packages being fetched from a registry, and it prints that same bar on a build you ran ten minutes ago. Layer caching cannot help here, because any change to the lockfile invalidates the install layer and forces the whole download to run again.
Confirm the download is actually re-running. Run a plain-progress build immediately after touching a lockfile and watch for the install step losing its CACHED marker:
#!/usr/bin/env bash
set -euo pipefail
# Force the install layer to miss by touching the manifest.
touch package.json
docker compose build --progress=plain app 2>&1 \
| grep -E 'RUN|CACHED|npm (ci|install)|Downloading'
Expected BAD output — the install step re-runs and the registry transfer starts from zero:
=> CACHED [app 3/6] COPY package.json package-lock.json ./
=> [app 4/6] RUN npm ci --prefer-offline 41.2s
=> => # npm http fetch GET 200 https://registry.npmjs.org/...
=> => # added 1183 packages in 39s
To put a number on the waste, measure two consecutive installs of an unchanged dependency tree. Without a cache mount, both builds transfer the full package set:
#!/usr/bin/env bash
set -euo pipefail
touch package.json && time docker compose build app >/dev/null 2>&1
touch package.json && time docker compose build app >/dev/null 2>&1
If both real times are within a few seconds of each other and both are dominated by download time, the package manager has no persistent cache between builds. That is exactly the gap RUN --mount=type=cache closes.
Root cause
Docker's ordinary build cache works at the granularity of a whole layer. When BuildKit reaches a RUN instruction, it hashes the instruction text plus the state of every file the preceding COPY brought in. If any of those inputs changed — and a one-character edit to package-lock.json counts — the layer is a cache miss and the entire command re-executes. A package manager started fresh inside that layer has an empty cache directory, so it re-downloads every dependency from the network even though 99% of them are byte-for-byte identical to last build.
A cache mount decouples the package manager's download cache from the image layer. RUN --mount=type=cache,target=/root/.npm attaches a host-managed directory to that single RUN step at build time. The directory is not part of the resulting image and is not hashed into the layer key; it simply persists on the build host and is re-attached on the next build. So when the layer misses and npm ci runs again, npm finds its tarballs already in /root/.npm and installs from disk instead of the network. The layer still rebuilds, but the expensive part — the transfer — is gone.
This is why cache mounts and correct layer ordering are complementary, not redundant. Ordering (copy the lockfile before the source) keeps the install layer cached when only source changes. Cache mounts make the install cheap on the days you genuinely change dependencies. You want both.
Resolution
The steps below convert an npm-based service to cache mounts, then show the equivalent mount for pip, apt, Go, and Cargo. Every mount lives inside the Dockerfile; no Compose change is required beyond ensuring BuildKit is the builder, which Docker Compose v2 does by default.
Opt in to the modern Dockerfile frontend.
RUN --mountis a BuildKit feature gated behind a syntax directive. Add it as the very first line of the Dockerfile so older parsers do not reject the flag:# syntax=docker/dockerfile:1 FROM node:20.11.0-alpine WORKDIR /appCopy only the manifest, then install with a cache mount. Keep the lockfile
COPYabove the sourceCOPYso the install layer stays cached on source-only edits, and attach npm's cache directory to the install step:COPY package.json package-lock.json ./ RUN \ npm ci --prefer-offline --no-audit COPY . .The
--prefer-offlineflag tells npm to use the cached tarballs before hitting the network, which is exactly what the mounted/root/.npmprovides. Run the build twice to confirm the second install reads from disk:#!/usr/bin/env bash set -euo pipefail touch package-lock.json && docker compose build app touch package-lock.json && time docker compose build appApply the same pattern to Python. pip stores wheels and the HTTP cache under
/root/.cache/pip. Mount it and pip will reuse built wheels instead of recompiling native packages:# syntax=docker/dockerfile:1 FROM python:3.12-slim WORKDIR /app COPY requirements.txt ./ RUN \ pip install --require-hashes -r requirements.txt COPY . .Cache apt without breaking the auto-clean. Debian images ship
/etc/apt/apt.conf.d/docker-clean, which deletes downloaded.debfiles after every install and would empty your cache. Disable it, keep downloaded archives, and mount both apt directories withsharing=lockedso two concurrent builds do not corrupt the lists:# syntax=docker/dockerfile:1 FROM debian:bookworm-slim RUN rm -f /etc/apt/apt.conf.d/docker-clean \ && echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \ > /etc/apt/apt.conf.d/keep-cache RUN \ apt-get update \ && apt-get install -y --no-install-recommends build-essentialCache Go modules and the compile cache. Go benefits twice: the module download cache and the build cache. Mount both, and note that the
GOFLAGSare not required because the default locations are what you mount:# syntax=docker/dockerfile:1 FROM golang:1.22 WORKDIR /src COPY go.mod go.sum ./ RUN \ go mod download COPY . . RUN \ go build -o /bin/app ./cmd/appCache the Cargo registry for Rust. Cargo re-downloads and re-indexes crates aggressively; mounting the registry and git caches turns cold dependency resolution into a disk read:
# syntax=docker/dockerfile:1 FROM rust:1.78 WORKDIR /app COPY Cargo.toml Cargo.lock ./ RUN \ cargo build --release
Choosing a sharing mode
Every cache mount has a sharing mode that controls what happens when two builds want the same cache at once. The default, shared, lets concurrent builds read and write simultaneously — correct for content-addressed caches like /root/.npm and /go/pkg/mod. Use locked for caches a tool assumes it owns exclusively, such as apt's lists, so BuildKit serialises access. Use private when a concurrent build should get its own fresh empty cache rather than block. Pick the wrong mode for apt and you get intermittent "could not get lock" or corrupted-index failures under parallel builds.
For a non-root image, add uid= and gid= matching the build user so the mounted directory is writable — for example --mount=type=cache,target=/home/node/.npm,uid=1000,gid=1000. Without it the install fails with a permission error the first time it tries to write a tarball.
Expected output
After the mounts are in place, run a dependency-changing build twice. The first primes the host cache; the second re-attaches it and installs from disk. BuildKit reports the install step completing in a fraction of the original time:
=> [app 4/6] RUN --mount=type=cache,target=/root/.npm npm ci 3.9s
=> => # added 1183 packages in 3s
=> exporting to image 0.4s
=> => writing image sha256:9f2c...
The npm ci line dropped from roughly forty seconds to under four, and the "added 1183 packages" line now completes in three seconds because no tarball crossed the network. Verify the cache actually holds data with a builder disk-usage report:
#!/usr/bin/env bash
set -euo pipefail
docker buildx du --verbose \
| grep -A2 'cache mount' || docker buildx du | tail -n 5
You should see a non-zero cache-mount reservation. The measured effect on total build time across the three states is stark once the download is removed from the critical path.
The middle bar is the point that surprises people: pure layer caching barely moves a build whose input actually changed, because the layer still misses. The cache mount is the only one of the three that survives a dependency change.
Prevention
To keep the speed-up from silently regressing, lock the behaviour into the repository and CI so a future edit cannot quietly reintroduce the full download.
Enforce the syntax directive with a check. A dropped
# syntax=docker/dockerfile:1line turns--mountinto a hard build error on old daemons. Add a pre-commit grep that fails when any Dockerfile using--mountlacks the directive on its first line:#!/usr/bin/env bash set -euo pipefail for f in $(git ls-files '*Dockerfile*'); do if grep -q -- '--mount=type=cache' "$f" \ && ! head -n1 "$f" | grep -q 'syntax=docker/dockerfile'; then echo "ERROR: $f uses cache mounts without a syntax directive"; exit 1 fi donePersist the cache across CI runs. Cache mounts live on the builder, so a fresh CI runner starts cold. Give the builder a durable store with
docker buildx build --cache-to type=local,dest=/tmp/bk --cache-from type=local,src=/tmp/bkand restore/tmp/bkfrom your CI cache between jobs, so CI benefits from the same disk-level reuse your laptop gets. This keeps local and CI build behaviour aligned, the same parity goal covered in optimizing Docker Compose for fast local rebuilds.Pin the manifest COPY above the source COPY in review. The cache mount removes the download cost, but layer ordering is what keeps the install skipped entirely on source-only edits. A lint rule or review checklist that rejects
COPY . .appearing before the dependency install preserves both wins together.
Platform caveats
macOS (Docker Desktop): The build cache lives inside the Docker Desktop Linux VM, not on the host APFS filesystem, so cache mounts are unaffected by macOS file-sharing overhead. If you run
docker builder pruneor the Desktop "Clean / Purge data" action, the cache mount is wiped and the next build repopulates it from the network — expect one slow build afterward.
WSL2: Keep your project and the Docker data root on the ext4 filesystem inside the WSL2 distribution, not on a
/mnt/cWindows path. Cache mounts themselves are stored in the builder and are fast either way, but a project mounted from/mnt/cmakes the surroundingCOPYsteps slow enough to mask the download savings.
Apple Silicon (ARM64): A cache mount is keyed by the build platform. If you build both
linux/arm64locally andlinux/amd64for deployment, each platform maintains a separate cache directory, so the first cross-platform build repopulates its own cache. This is correct — an amd64 wheel is not usable on arm64 — but budget for two cold builds when you introduce a second--platformtarget.
Rollback
Cache mounts are a build-time-only construct, so reverting is a Dockerfile edit with no effect on any produced image. Remove the --mount flag from the affected RUN line and rebuild:
#!/usr/bin/env bash
set -euo pipefail
sed -i 's|RUN --mount=type=cache[^ ]* |RUN |g' Dockerfile
docker compose build --no-cache app
If you only want to discard the cached contents without changing the Dockerfile, prune just the cache mounts and leave the layer cache intact:
docker buildx prune --filter type=exec.cachemount --force
Frequently Asked Questions
Do cache mount contents end up inside my final image?
No. A type=cache mount is attached only for the duration of that single RUN instruction and is never committed to a layer. The downloaded tarballs in /root/.npm or the wheels in /root/.cache/pip stay on the build host and are excluded from the image, so they do not add to image size. This is the key difference from copying a cache directory into the image with COPY.
Why is RUN --mount=type=cache rejected as a syntax error?
The --mount flag requires the BuildKit Dockerfile frontend, enabled by putting # syntax=docker/dockerfile:1 as the very first line of the Dockerfile, and building with BuildKit as the engine. Docker Compose v2 uses BuildKit by default; with the legacy builder or an old daemon the parser does not recognise the flag and fails. Confirm the directive is on line one and that docker buildx version reports a builder.
Does docker builder prune delete my cache mounts?
Yes. docker builder prune and docker buildx prune reclaim build cache including cache mounts, so the next build starts with an empty download cache and repopulates it from the network once. To target only cache mounts and preserve the layer cache, use docker buildx prune --filter type=exec.cachemount. A full docker system prune -a also removes them.
What sharing mode should I use for apt versus npm?
Use the default shared mode for content-addressed caches like npm's /root/.npm, pip's /root/.cache/pip, and Go's /go/pkg/mod, because concurrent writes to distinct hashed files are safe. Use sharing=locked for apt's /var/cache/apt and /var/lib/apt, since apt assumes exclusive access to its lists and parallel builds can otherwise corrupt the index or hit lock errors.