Skip to content
Blog

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:1 at 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=locked so parallel builds wait for each other instead of corrupting shared state. npm, pip, Go, and Cargo handle concurrent readers; still, add sharing=locked if you see flaky parallel builds.
  • Cache mounts are keyed by their mount options, so separate targets are separate caches: /root/.npm for npm, /root/.cache/pip for pip, /go/pkg/mod plus /root/.cache/go-build for Go, /var/cache/cargo for Cargo.

Cache invalidation gotchas

  • No lockfile means no stable key. Without package-lock.json, uv.lock, go.sum, or Cargo.lock copied 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.
  • RUN cache never expires on its own. Docker docs are explicit: rebuilding a week later with a cached RUN apk add curl gives you the same packages as before. Force freshness with --pull for base images, --no-cache or --no-cache-filter when you really need a clean rebuild, or by rotating a CACHEBUST build 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 build linux/arm64. Prefer cache mounts keyed by TARGETPLATFORM (for example target=/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 CACHEBUST build arg alongside it when a secret change must take effect.
  • SOURCE_DATE_EPOCH with a dynamic value (like a Git commit timestamp) busts WORKDIR and 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 . . before npm ci guarantees 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, and CARGO_HOME on /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.
  • RUN layers 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-from with type=gha,mode=max or type=registry, and pair cache mounts with buildkit-cache-dance if you want them to persist.
  • Verify with BUILDKIT_PROGRESS=plain and compare the install step’s DONE time across cold, cached, and invalidated builds.

References