Skip to content
Blog

BuildKit Cache Mounts: Fast Package Installs in Docker Builds

RUN --mount=type=cache gives Docker builds a persistent, shared cache across builds — pip, npm, apt, Go modules, and Cargo all get dramatically faster with it.

Published on • October 28, 2026

AI Assistant

If your Docker build spends four minutes re-downloading node_modules because you changed one source file, you’re hitting the limits of layer caching — and the fix isn’t a cleverer COPY order. It’s BuildKit’s cache mount: RUN --mount=type=cache, which gives build steps a persistent scratch directory that survives across builds, shared across builds on the same machine, and invisible in the final image.

Why layer caching isn’t enough

Docker’s classic caching model: each Dockerfile instruction is a layer, cached by hashing its inputs. COPY package.json then RUN npm install works well — until anything in package.json changes. The install layer is invalidated, and npm re-downloads every package from the network, even though 99% of the tarballs are identical to last build. The registry cache (--cache-from) helps across machines but not for this problem: the layer genuinely changed, so there’s no cache hit to reuse.

What you actually want is a cache keyed not on the Dockerfile instruction, but on the dependency set — and that’s precisely what a cache mount provides. It decouples “the cacheable data” (downloaded packages) from “the layer” (the instruction that produced it).

The mechanics

# syntax=docker/dockerfile:1
FROM node:22-slim

WORKDIR /app

# Mount npm's cache dir into this RUN only
RUN --mount=type=cache,target=/root/.npm \
    npm ci --no-audit

COPY . .
RUN npm run build

Three properties make this work:

  • The mount exists only during that RUN. It’s not part of any layer, so it never bloats the image, and it doesn’t invalidate caching the way COPYing a cache directory in would.
  • It persists between builds. The cache directory lives in BuildKit’s internal storage, keyed by the mount’s id (defaults to the target path). The next build of any Dockerfile on that daemon with the same id starts warm.
  • It’s safe for concurrency. Builds sharing a cache mount are serialized on that mount, so two parallel builds won’t corrupt each other’s npm cache.

Adapting it per package manager

Each tool needs its cache directory mounted — the trick is just the cache, not the whole home dir:

Tooltarget=Extra flags
npm/root/.npm—
pnpm/root/.local/share/pnpm/storepnpm config set store-dir first
pip/root/.cache/pip—
Go modules/go/pkg/modoften GOPATH=/go too
Cargo/usr/local/cargo/registryset CARGO_HOME accordingly
apt/var/lib/apt/lists + /var/cache/aptclean lists in same RUN

For pnpm and Cargo, the cache directory the tool expects may differ from where you mount — set the tool’s env var (pnpm store dir, CARGO_HOME) to point at your mount target rather than fighting it.

apt deserves a mention: mounting /var/lib/apt/lists makes apt-get update nearly free (the metadata download was most of the cost), but the packages still download unless you also cache /var/cache/apt. Remember rm -rf /var/lib/apt/lists/* at the end of the same RUN — with the mount, it no longer costs you anything.

Beyond packages: monorepos and compiler caches

Cache mounts shine anywhere expensive, reusable state passes through a build step:

  • Monorepo partial builds — mount the build system’s incremental directory (e.g., Turborepo’s .turbo, Nx’s cache) so a changed package doesn’t rebuild its untouched siblings.
  • Language compiler caches — Go build cache, Rust target/ (carefully), ccache for C/C++.
  • Recurring downloads — Terraform providers, Chrome for Playwright/Puppeteer installs.

Debugging and hygiene

If cache mounts seem to do nothing, check the obvious first: the # syntax=docker/dockerfile:1 line (or BuildKit enabled via DOCKER_BUILDKIT=1) — without BuildKit, the instruction fails outright. On BuildKit, docker buildx du shows cache usage; if it grows unboundedly, scope mounts with an explicit id= per project and prune periodically.

One security note: cache mounts persist between builds, so a compromised build could plant content that a later build reads. For untrusted Dockerfiles, run builds with isolated cache (--no-cache-filter or dedicated builders) rather than sharing the daemon-wide cache.

The bottom line

Layer caching answers “did this instruction change?”; cache mounts answer “have I downloaded this package before?” — two different questions that happen to look identical in a Dockerfile. RUN --mount=type=cache gives package managers (and compilers, and incremental builders) a persistent, shared, out-of-image scratch directory, turning dependency-heavy builds from minutes into seconds. It’s the single highest-leverage line you can add to most Dockerfiles.