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.

Layer cache only versus layer cache plus cache mount On a lockfile change both approaches rebuild the install layer, but only the cache mount reuses downloaded packages from the build host. On a Lockfile Change Layer cache only Install layer: MISS Cache dir starts empty Re-download all packages ~40s over network Plus cache mount Install layer: MISS Cache dir re-attached Install from local disk ~4s from disk
The layer misses in both cases; the cache mount removes the network transfer, not the layer rebuild.

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.

  1. Opt in to the modern Dockerfile frontend. RUN --mount is 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 /app
  2. Copy only the manifest, then install with a cache mount. Keep the lockfile COPY above the source COPY so 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 --mount=type=cache,target=/root/.npm \
        npm ci --prefer-offline --no-audit
    COPY . .

    The --prefer-offline flag tells npm to use the cached tarballs before hitting the network, which is exactly what the mounted /root/.npm provides. 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 app
  3. Apply 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 --mount=type=cache,target=/root/.cache/pip \
        pip install --require-hashes -r requirements.txt
    COPY . .
  4. Cache apt without breaking the auto-clean. Debian images ship /etc/apt/apt.conf.d/docker-clean, which deletes downloaded .deb files after every install and would empty your cache. Disable it, keep downloaded archives, and mount both apt directories with sharing=locked so 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 --mount=type=cache,target=/var/cache/apt,sharing=locked \
        --mount=type=cache,target=/var/lib/apt,sharing=locked \
        apt-get update \
        && apt-get install -y --no-install-recommends build-essential
  5. Cache Go modules and the compile cache. Go benefits twice: the module download cache and the build cache. Mount both, and note that the GOFLAGS are 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 --mount=type=cache,target=/go/pkg/mod \
        go mod download
    COPY . .
    RUN --mount=type=cache,target=/go/pkg/mod \
        --mount=type=cache,target=/root/.cache/go-build \
        go build -o /bin/app ./cmd/app
  6. Cache 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 --mount=type=cache,target=/usr/local/cargo/registry \
        --mount=type=cache,target=/app/target \
        cargo build --release
Where a cache mount attaches during a build The RUN step attaches a persistent host directory, so the package manager installs from that directory instead of the network. Cache Mount Data Path RUN step npm ci / pip install Cache mount target=/root/.npm Host cache dir survives builds The mount is excluded from the image layer and the layer cache key.
The mounted directory lives on the build host, not in the image, so it persists across every build.

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.

Choosing a cache mount sharing mode A decision between shared mode for content-addressed caches and locked mode for tools that assume exclusive access. Which Sharing Mode? Does the tool assume it owns the cache exclusively? No: shared (default) npm, pip, go modules Yes: sharing=locked apt lists and archives
Content-addressed caches are safe to share; index-based caches need locked to avoid concurrent corruption.

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.

Dependency-change build time by cache strategy Bar chart comparing build seconds for no cache, layer cache only, and layer cache plus cache mount. Build After Lockfile Change (seconds) no cache 210s layer cache 165s + cache mount 24s
Layer caching alone cannot help a lockfile change; the cache mount is what collapses the download cost.

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.

  1. Enforce the syntax directive with a check. A dropped # syntax=docker/dockerfile:1 line turns --mount into a hard build error on old daemons. Add a pre-commit grep that fails when any Dockerfile using --mount lacks 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
    done
  2. Persist 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/bk and restore /tmp/bk from 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.

  3. 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 prune or 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/c Windows path. Cache mounts themselves are stored in the builder and are fast either way, but a project mounted from /mnt/c makes the surrounding COPY steps slow enough to mask the download savings.

Apple Silicon (ARM64): A cache mount is keyed by the build platform. If you build both linux/arm64 locally and linux/amd64 for 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 --platform target.

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.