Dependency Caching for npm, pip, Go Modules, and Cargo
Two layers of build caching for language dependencies: lockfile-first COPY ordering plus BuildKit cache mounts, with Dockerfiles for npm, pip, Go, and Cargo.
Published on • October 16, 2026
AI Assistant

Dependency downloads are the slowest part of most application image builds, and the cache rules that govern them are simple enough to reason about but easy to get subtly wrong. Docker’s builder reuses a layer only when the instruction and the files it depends on have not changed, and once a layer is invalidated every later layer rebuilds too. That single mechanic is the whole game: put the expensive, rarely-changing dependency install in its own layers, and put your churning source code after them. This post covers the two-layer strategy in practice: lockfile-first ordering, then BuildKit cache mounts, for npm, pip (including uv), Go modules, and Cargo.
Layer one: copy lockfiles before source
The canonical mistake looks like this:
FROM node
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
Any file change invalidates COPY . ., so every edit reinstalls every dependency. The Docker docs show the fix: split the COPY so package management files land first.
# syntax=docker/dockerfile:1
FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Now editing src/app.ts re-runs the source COPY and build, while the install layer stays cached. The cache checksum for COPY comes from file metadata (modification times are ignored), so only real content changes bust it. The same shape applies everywhere: lockfiles first, install, then source.
npm
COPY package.json package-lock.json ./
RUN npm ci
Use npm ci rather than npm install when a lockfile exists: it installs exactly what the lockfile says instead of re-resolving ranges. Keep node_modules out of the build context with .dockerignore so the context upload stays small and unrelated files do not churn your COPY checksums:
node_modules
npm-debug.log
dist
pip (requirements and uv)
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
That fragment combines both layers of the strategy: the COPY order protects the install layer, and the cache mount keeps wheels across cache invalidations. The docs also show a bind-mount variant that avoids creating a layer for the requirements file at all:
RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \
pip install --requirement /tmp/requirements.txt
For uv projects, copy pyproject.toml and uv.lock, then sync from the lockfile with a cache mount on uv’s cache directory:
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen
Go modules
Go benefits from two caches: the module download cache and the build artifact cache. The docs use both, plus bind mounts so go.mod and go.sum drive the cache key without becoming a layer:
# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=bind,source=go.mod,target=go.mod \
--mount=type=bind,source=go.sum,target=go.sum \
go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=bind,target=. \
go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
Keep GOFLAGS=-mod=readonly (or rely on go mod download alone) so a build step cannot silently rewrite go.mod, which would break reproducibility. The /root/.cache/go-build mount is where GOCACHE lives by default; without it, a cache miss on the build layer recompiles the entire dependency graph from scratch.
Cargo
The docs’ Rust example mounts the registry cache and the target directory, and points CARGO_HOME at the mounted cache:
FROM rust:1.85 AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
RUN --mount=type=cache,target=/var/cache/cargo \
CARGO_HOME=/var/cache/cargo cargo fetch
COPY . .
RUN --mount=type=cache,target=/var/cache/cargo \
--mount=type=cache,target=/src/target \
CARGO_HOME=/var/cache/cargo cargo build --release --locked
FROM debian:bookworm-slim
COPY --from=build /src/target/release/app /usr/local/bin/app
CMD ["app"]
Docker’s docs note that a single CARGO_HOME lets concurrent builds coordinate through Cargo’s own cache lock, that setting CARGO_HOME also changes where Cargo looks for global config and credentials, and that project-level .cargo/config.toml files are unaffected. Use --locked so the build fails instead of updating Cargo.lock.
Layer two: cache mounts survive invalidation
Regular layer caching is all-or-nothing: change the instruction or its files and the layer, including its downloaded packages, is thrown away. Cache mounts are different. RUN --mount=type=cache,target=/root/.npm mounts a persistent directory that is cumulative across builds and never becomes part of the image. When the layer rebuild, only new or changed packages get downloaded; everything else is reused.
Rules worth memorizing:
- Cache mounts require BuildKit. It is the default builder on Docker Desktop and Docker Engine 23.0+; add
# syntax=docker/dockerfile:1at the top of the Dockerfile. - Nothing written to the mount lands in the final image, which is why this is the correct place for package caches instead of
ENV npm_config_cache. - Not every manager tolerates concurrent access. The docs call this out for apt: use
sharing=lockedso parallel builds wait for each other instead of corrupting shared state. npm, pip, Go, and Cargo handle concurrent readers; still, addsharing=lockedif you see flaky parallel builds. - Cache mounts are keyed by their mount options, so separate targets are separate caches:
/root/.npmfor npm,/root/.cache/pipfor pip,/go/pkg/modplus/root/.cache/go-buildfor Go,/var/cache/cargofor Cargo.
Cache invalidation gotchas
- No lockfile means no stable key. Without
package-lock.json,uv.lock,go.sum, orCargo.lockcopied into the layer, your install layer may be a floating resolver that produces different results on every build. Copy the lockfile, install from it, and commit it. RUNcache never expires on its own. Docker docs are explicit: rebuilding a week later with a cachedRUN apk add curlgives you the same packages as before. Force freshness with--pullfor base images,--no-cacheor--no-cache-filterwhen you really need a clean rebuild, or by rotating aCACHEBUSTbuild arg.- Platform-specific installs poison multi-arch builds. An install layer that resolves native binaries for the build machine’s architecture (esbuild-style optional dependencies, Go build caches, Cargo
target/artifacts) will produce wrong or non-portable output when you later buildlinux/arm64. Prefer cache mounts keyed byTARGETPLATFORM(for exampletarget=/root/.cache/go-build/${TARGETPLATFORM}) or accept a per-platform cache miss rather than sharing one. - Secrets do not invalidate cache. Changing a build secret does not rebuild anything; pass a
CACHEBUSTbuild arg alongside it when a secret change must take effect. SOURCE_DATE_EPOCHwith a dynamic value (like a Git commit timestamp) bustsWORKDIRand everything after it on every commit. Use a fixed value for reproducible builds.
CI: export and import the cache
Local cache dies with the runner. BuildKit’s external cache backends fix that: --cache-from imports, --cache-to exports. The supported backends are inline, local, registry, gha, plus s3 and azblob; note that the default docker driver only supports inline, local, registry, and gha, and only with the containerd image store enabled.
Registry cache in GitHub Actions:
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
push: true
tags: user/app:latest
cache-from: type=registry,ref=user/app:buildcache
cache-to: type=registry,ref=user/app:buildcache,mode=max
GitHub Actions cache backend:
cache-from: type=gha
cache-to: type=gha,mode=max
mode=min (the default) exports only layers that end up in the resulting image; mode=max exports every layer, including intermediate stages like your dependency installs, and is much more likely to hit. As of April 15, 2025 only GitHub Cache service API v2 is supported, which means Buildx >= v0.21.0 and BuildKit >= v0.20.0 (GitHub-hosted runners using docker/build-push-action are already current; self-hosted runners may need docker/setup-buildx-action with version: latest). Some events (issue_comment, pull_request_target) get read-only cache access, so keep cache-from: type=gha, drop cache-to, and make export failures non-fatal with cache-to: type=gha,mode=max,ignore-error=true.
One gap to plan for: BuildKit does not preserve cache mounts in the GitHub Actions cache by default. To reuse /root/.npm or /go/pkg/mod between CI runs you need a workaround such as reproducible-containers/buildkit-cache-dance together with actions/cache to extract and re-inject the mount data.
Measuring the win
Do not trust vibes; read the timings:
$ BUILDKIT_PROGRESS=plain docker build -t app .
#9 [stage-0 4/8] RUN --mount=type=cache,target=/root/.npm npm ci
#9 DONE 41.3s
Run it twice, then change only a source file and run it a third time. The number you care about is the install step: cold it is tens of seconds, warm-with-lockfile it should print CACHED, and warm-with-cache-mount-but-invalidated-layer it should be a couple of seconds of diffing rather than a full redownload. You can also pass --progress=plain on the CLI; the BUILDKIT_PROGRESS environment variable sets the same value.
Key Takeaways
- Order the Dockerfile by change frequency: lockfiles, install, then source.
COPY . .beforenpm ciguarantees a reinstall on every edit. - Cache mounts (
RUN --mount=type=cache,target=...) survive layer invalidation and never enter the image; use/root/.npm,/root/.cache/pip,/go/pkg/mod+/root/.cache/go-build, andCARGO_HOMEon/var/cache/cargo. - Commit and copy lockfiles (
package-lock.json,uv.lock,go.sum,Cargo.lock); without them your install layer is neither reproducible nor stably cached. RUNlayers never expire on their own, secrets do not trigger rebuilds, and platform-specific installs need per-platform cache keys.- In CI use
--cache-to/--cache-fromwithtype=gha,mode=maxortype=registry, and pair cache mounts withbuildkit-cache-danceif you want them to persist. - Verify with
BUILDKIT_PROGRESS=plainand compare the install step’sDONEtime across cold, cached, and invalidated builds.
References
- https://docs.docker.com/build/cache/
- https://docs.docker.com/build/building/best-practices/
- https://docs.docker.com/build/cache/optimize
- https://docs.docker.com/build/cache/invalidation/
- https://docs.docker.com/build/cache/backends/
- https://docs.docker.com/build/ci/github-actions/cache
- https://docs.docker.com/reference/cli/docker/buildx/build/