| |

Docker 25 🐳 BuildKit Cache Mounts: Accelerating Package Manager Caches (npm, pip, apt, cargo)

A Docker build that reinstalls every dependency on every run is a build that wastes time. Package managers download from the network, unpack archives, and write to disk—work that is identical across builds unless the dependency files change. Layer caching helps only when the instruction sequence and inputs are unchanged. The moment you touch a source file, the layer that installed dependencies is invalidated, and the downloads start from scratch. BuildKit cache mounts solve this by persisting package manager caches across builds without storing them in the image.

This chapter covers --mount=type=cache, the BuildKit feature that keeps package manager downloads, compiler caches, and build artifacts between builds. You will learn the syntax, the correct cache directories for npm, pip, apt, and cargo, the sharing modes that control concurrent access, and the interaction with layer caching that determines whether the mount actually helps. The result is builds that stay fast even as source code changes.

Key point: A cache mount is a directory that persists across builds but never becomes part of the image. It is a scratch space managed by BuildKit, mounted into a RUN instruction, and detached when the instruction completes. The cache lives outside the layer filesystem, so it does not bloat the image and is not invalidated by layer cache misses.


Why cache mounts exist

The layer cache limitation. Docker’s layer cache invalidates a RUN instruction when any of its inputs change. For a RUN npm ci instruction, the inputs are the package.json and package-lock.json files copied before it. If those files change—a dependency version bump, a new package—the layer is invalidated, and npm ci downloads everything again. This is correct behavior; the dependencies may have changed. But it is expensive. The layer cache cannot help because it does not know which packages actually changed.

The cache mount solution. A cache mount sits outside the layer cache. It persists independently of whether the RUN instruction is re-executed. When npm ci runs, it checks its cache directory for packages it already has. If a package is present, it is unpacked from the local cache instead of downloaded. Only new or changed packages hit the network. The layer still rebuilds, but the expensive part—the network downloads—is avoided.

The image size problem. Installing dependencies with a cache would normally bloat the image if the cache were stored in a layer. BuildKit cache mounts avoid this because the cache directory is mounted from a volume outside the layer filesystem. The packages downloaded during npm ci go into the mounted cache, not into the layer. The layer contains only the installed node_modules, not the tarballs that produced them.

The apt problem. The Debian package manager maintains two caches: /var/cache/apt for downloaded .deb files, and /var/lib/apt/lists for the package index. The standard Docker pattern is apt-get update && apt-get install && rm -rf /var/lib/apt/lists/* to avoid shipping the index in the image. With a cache mount on both directories, the index and packages persist across builds, and the cleanup step is unnecessary because they never enter the layer .

The CI problem. Cache mounts persist on the build host. In local development, where the same Docker daemon runs the same build repeatedly, they work automatically. In CI, where each build runs on a fresh runner, the cache mount is empty every time. Persisting cache mounts across CI runs requires exporting the cache—using actions/cache to save and restore the BuildKit cache directory, or using a tool like buildkit-cache-dance to inject and extract the cache .


a. Basic syntax and package manager cache directories

The syntax is a flag on a RUN instruction: --mount=type=cache,target=/path. The target is the directory inside the build container where the cache is mounted.

The Dockerfile must begin with the syntax directive to enable the feature:

# syntax=docker/dockerfile:1

Each package manager has a standard cache directory that should be the mount target.

npm stores its cache in ~/.npm, which is /root/.npm for the root user .

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

COPY . .
CMD ["node", "index.js"]

The --omit=dev flag installs only production dependencies. The cache mount persists the npm cache, so subsequent builds reuse downloaded tarballs.

pip stores its cache in ~/.cache/pip, which is /root/.cache/pip . The --no-cache-dir flag must be removed when using a cache mount, because it disables the cache entirely .

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

COPY . .
CMD ["python", "app.py"]

apt has two cache directories: /var/cache/apt for downloaded .deb packages, and /var/lib/apt/lists for the package index . Both should be mounted.

FROM debian:trixie-slim

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 \
    curl \
    ca-certificates

The rm -rf /var/lib/apt/lists/* cleanup is intentionally omitted. With the cache mount, the lists never enter the image layer, so cleanup provides no benefit and would defeat the cache .

cargo (Rust) uses /usr/local/cargo/registry for the crate registry, /usr/local/cargo/git for git dependencies, and target/ for compiled artifacts. The registry and git directories should be cached; the target directory should also be cached for incremental compilation .

FROM rust:1-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./

RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=/app/target \
    cargo build --release --locked

The --locked flag ensures the lock file is respected. The target cache means that recompiling only the application code—not every dependency—is possible on subsequent builds.


b. Sharing modes and cache identity

Cache mounts have options that control how concurrent builds access the same cache.

sharing=shared (default) allows multiple builds to use the cache simultaneously. This is appropriate for read-heavy caches like npm or pip, where concurrent access does not cause corruption .

sharing=locked serializes access: only one build can use the cache at a time. This is required for apt, where concurrent apt-get update operations can conflict .

sharing=private gives each build its own copy of the cache. This is rarely needed but is available when isolation is required .

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 curl

id assigns a name to the cache. Caches with different IDs are separate even if they have the same target. This is useful when multiple stages use the same cache directory for different purposes .

RUN --mount=type=cache,id=node-deps,target=/root/.npm \
    npm ci

The id is also the mechanism for sharing a cache between stages. Two stages that mount the same id and target share the cache directory .

ro / readonly mounts the cache as read-only. This is useful for a stage that consumes a cache populated by an earlier stage .

uid and gid set the ownership of the cache directory. When building as a non-root user, the cache must be writable by that user .

RUN --mount=type=cache,target=/root/.npm,uid=1000,gid=1000 \
    npm ci

Without the correct uid, a non-root build user cannot write to the cache, and the mount provides no benefit.


c. Layer caching interaction and CI persistence

Cache mounts and layer caching are independent mechanisms, but they interact in ways that affect whether a build actually speeds up.

The layer cache determines whether a RUN instruction executes at all. If the instruction’s inputs are unchanged, the layer is reused, and the command does not run. The cache mount is irrelevant in this case—the RUN never executes, so the cache is never accessed.

When the layer cache misses—because a source file changed or a dependency file was modified—the RUN instruction executes. The cache mount now matters: the package manager checks its cache directory before hitting the network. If the cache is populated from a previous build, it reuses what it can.

This means that cache mounts help most when:

  • The layer is invalidated frequently (source changes often).
  • The dependencies are large and stable (rarely change).
  • The cache persists across builds (local development, persistent CI runners).

The cache mount does not help when the layer cache already hits—the instruction does not execute—or when the cache is empty (fresh CI runner without persisted cache).

Persisting cache mounts in CI requires exporting the BuildKit cache directory. GitHub Actions does not preserve cache mount contents between workflow runs by default. The standard approaches are:

  1. actions/cache with the BuildKit cache directory. Save and restore the directory that BuildKit uses for cache mounts. The cache key should hash the dependency files (package-lock.json, Cargo.lock, requirements.txt) so that new entries are created only when dependencies change .
  2. reproducible-containers/buildkit-cache-dance. A tool that injects cache data into the BuildKit builder before the build and exports it afterward. It maps cache mount IDs to directories that actions/cache can persist .

The cache key design matters. Hashing the commit SHA creates a new cache entry on every push, which quickly exhausts the GitHub Actions cache quota. Hashing the dependency files creates a new entry only when dependencies change, which is far more efficient .

A CI configuration that persists cargo and ccache caches reduced a 16-minute build to 9–10 minutes in one case. The remaining time was the unavoidable recompilation of the application’s own crates and the LTO link step, which cannot be cached across source changes .


Complete Example Session

# ============================================
# PART 1: NPM WITH CACHE MOUNT
# ============================================
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
COPY . .
CMD ["node", "index.js"]
# ============================================
# PART 2: PIP WITH CACHE MOUNT
# ============================================
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
# ============================================
# PART 3: APT WITH CACHE MOUNT
# ============================================
FROM debian:trixie-slim
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 \
    curl ca-certificates
# Note: no rm -rf /var/lib/apt/lists/*
# ============================================
# PART 4: CARGO WITH CACHE MOUNT
# ============================================
FROM rust:1-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=/app/target \
    cargo build --release --locked
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=/app/target \
    cargo build --release --locked && \
    cp target/release/app /usr/local/bin/app

FROM alpine:3.22
COPY --from=builder /usr/local/bin/app /app
CMD ["/app"]
# ============================================
# PART 5: GO WITH CACHE MOUNT
# ============================================
FROM golang:1.25 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /server
# ============================================
# PART 6: SHARING MODES
# ============================================
# npm: shared (default, read-heavy)
RUN --mount=type=cache,target=/root/.npm,sharing=shared npm ci

# apt: locked (write-heavy, serialized)
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 curl
# ============================================
# PART 7: CACHE ID FOR MULTIPLE STAGES
# ============================================
FROM node:22 AS deps
RUN --mount=type=cache,id=npm-cache,target=/root/.npm npm ci

FROM node:22 AS build
COPY --from=deps /app/node_modules ./node_modules
RUN --mount=type=cache,id=npm-cache,target=/root/.npm npm run build
# ============================================
# PART 8: NON-ROOT USER CACHE
# ============================================
FROM node:22-alpine
RUN adduser -D appuser
USER appuser
WORKDIR /home/appuser
RUN --mount=type=cache,target=/home/appuser/.npm,uid=1000,gid=1000 \
    npm ci
# ============================================
# PART 9: COMBINING SECRET AND CACHE
# ============================================
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    --mount=type=cache,target=/root/.npm \
    npm ci
# ============================================
# PART 10: CI PERSISTENCE (GITHUB ACTIONS)
# ============================================
# - name: Cache BuildKit mounts
#   uses: actions/cache@v4
#   with:
#     path: /tmp/buildkit-cache
#     key: buildkit-${{ hashFiles('Cargo.lock') }}
#
# - name: Build with cache
#   uses: reproducible-containers/buildkit-cache-dance@v3.4.0
#   with:
#     cache-map: '{"cargo-registry":"/tmp/buildkit-cache/cargo-registry"}'
#     build-args: .

The ten parts covered npm, pip, apt, cargo, Go, sharing modes, cache IDs, non-root users, combining secrets and caches, and CI persistence.


Quick Reference

Package Manager Cache Directories

ManagerCache DirectorySharingNotes
npm/root/.npmsharedDefault
pip/root/.cache/pipsharedRemove --no-cache-dir
apt/var/cache/apt and /var/lib/apt/listslockedOmit rm -rf cleanup
cargo/usr/local/cargo/registry, /usr/local/cargo/gitsharedPlus target/
Go/go/pkg/mod, /root/.cache/go-buildsharedModule and build cache
Maven/root/.m2/repositorysharedStandard pattern

Mount Options

OptionPurposeExample
targetMount path inside containertarget=/root/.npm
idCache identity for sharingid=npm-cache
sharingConcurrent access modesharing=locked
roRead-only cachero
uid/gidOwnership for non-rootuid=1000,gid=1000

Sharing Modes

ModeBehaviorUse Case
sharedMultiple builds concurrentlynpm, pip, cargo
lockedOne build at a timeapt
privateEach build gets a copyIsolation

Requirements

RequirementNotes
BuildKitDocker 23+ default
Syntax directive# syntax=docker/dockerfile:1
CI persistenceactions/cache + buildkit-cache-dance

Best Practices

✅ Do This:

# Add syntax directive
# syntax=docker/dockerfile:1                                        # ✅

# Mount the correct cache directory per package manager
RUN --mount=type=cache,target=/root/.npm npm ci                      # ✅

# Use sharing=locked for apt
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    apt-get update && apt-get install -y curl                        # ✅

# Remove --no-cache-dir when using pip cache mount
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt                                  # ✅

# Omit rm -rf for apt when using cache mount
# (lists never enter the layer)                                     # ✅

# Use id for sharing between stages
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry # ✅

❌ Don’t Do This:

# Don't forget the syntax directive
RUN --mount=type=cache,target=/root/.npm npm ci                      # ❌

# Don't use --no-cache-dir with a pip cache mount
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir -r requirements.txt                   # ❌

# Don't use sharing=shared for apt
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
    apt-get update                                                   # ❌

# Don't keep rm -rf /var/lib/apt/lists/* with apt cache mount
RUN --mount=type=cache,target=/var/lib/apt \
    apt-get update && apt-get install -y curl && \
    rm -rf /var/lib/apt/lists/*                                      # ❌

# Don't expect cache mounts to persist in CI without export
# (fresh runner = empty cache)                                     # ⚠️

Common Pitfalls

PitfallWhy It HappensFix
Cache mount ignoredBuildKit not enabledDOCKER_BUILDKIT=1 or Docker 23+
Pip cache not used--no-cache-dir presentRemove the flag
Apt cache brokensharing=sharedUse sharing=locked
Non-root can’t write cacheMissing uid/gidAdd uid=1000,gid=1000
CI build still slowCache not persistedUse actions/cache + export
Cache key exhausts quotaHashing commit SHAHash dependency files
Cleanup defeats cacherm -rf with apt mountOmit cleanup

Real-World Examples

1. Node.js Production Build

RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

2. Python with Requirements

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

3. Apt with Serialized 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 curl

4. Rust with Registry and Target

RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/app/target \
    cargo build --release

5. Go Modules and Build Cache

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /server

6. Cache ID for Multi-Stage

RUN --mount=type=cache,id=shared-cache,target=/root/.npm npm ci

7. Non-Root User

USER appuser
RUN --mount=type=cache,target=/home/appuser/.cache,uid=1000,gid=1000 \
    pip install --user -r requirements.txt

8. Secret + Cache Together

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    --mount=type=cache,target=/root/.npm \
    npm ci

9. GitHub Actions Persistence

- uses: reproducible-containers/buildkit-cache-dance@v3.4.0
  with:
    cache-map: '{"cargo-registry":"/tmp/cache/cargo"}'

10. Developer Iteration

# First build: downloads all dependencies
docker build -t myapp .

# Change source file
# Second build: reuses cached packages
docker build -t myapp .

Visual

Cache Mount vs Layer Cache

┌─────────────────────────────────────────────────────────────┐
│  LAYER CACHE                                                │
│                                                             │
│  RUN npm ci                                                 │
│  ├── Inputs: package.json, package-lock.json               │
│  ├── Hit: instruction not executed                          │
│  └── Miss: instruction executes, downloads from network     │
│                                                             │
│  Layer cache is invalidated when inputs change.             │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  CACHE MOUNT                                                │
│                                                             │
│  RUN --mount=type=cache,target=/root/.npm npm ci            │
│  ├── Layer cache miss: npm ci executes                      │
│  ├── npm checks /root/.npm for cached packages              │
│  ├── Cached packages: unpacked from local cache             │
│  └── New packages: downloaded and added to cache            │
│                                                             │
│  Cache persists across builds, outside the layer.           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Cache Mount Lifecycle

┌─────────────────────────────────────────────────────────────┐
│  CACHE MOUNT LIFECYCLE                                      │
│                                                             │
│  Build 1:                                                   │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  RUN npm ci                                         │    │
│  │  ├── Cache empty                                    │    │
│  │  ├── Downloads all packages                         │    │
│  │  └── Writes to cache volume                         │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Cache persists (BuildKit volume)                           │
│                                                             │
│  Build 2 (source changed):                                  │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  RUN npm ci                                         │    │
│  │  ├── Cache populated                                │    │
│  │  ├── Reads packages from cache                      │    │
│  │  └── Downloads only new/changed packages            │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Cache does not appear in image layers.                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Apt Cache Mount Pattern

┌─────────────────────────────────────────────────────────────┐
│  WITHOUT CACHE MOUNT                                        │
│                                                             │
│  RUN apt-get update && apt-get install -y curl && \         │
│      rm -rf /var/lib/apt/lists/*                            │
│                                                             │
│  Each build:                                                │
│  ├── Downloads package index                                │
│  ├── Downloads .deb files                                   │
│  └── Removes index (but not the .deb cache)                 │
│                                                             │
│  Network cost every build.                                  │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  WITH CACHE MOUNT                                           │
│                                                             │
│  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 curl              │
│                                                             │
│  First build:                                               │
│  ├── Downloads index and .deb files                         │
│  └── Writes to cache volumes                                │
│                                                             │
│  Subsequent builds:                                         │
│  ├── Index and .deb files already cached                    │
│  ├── Only new packages downloaded                           │
│  └── No cleanup needed (cache outside layer)                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Sharing Modes

┌─────────────────────────────────────────────────────────────┐
│  sharing=shared (DEFAULT)                                   │
│                                                             │
│  Build A ──▶ ┌─────────────┐ ◀── Build B                    │
│              │   Cache     │                                │
│              │  (concurrent)│                               │
│              └─────────────┘                                │
│                                                             │
│  Both builds access cache simultaneously.                   │
│  Safe for read-heavy caches (npm, pip, cargo).              │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  sharing=locked                                             │
│                                                             │
│  Build A ──▶ ┌─────────────┐                                │
│              │   Cache     │                                │
│              │  (exclusive)│                                │
│              └─────────────┘                                │
│                                                             │
│  Build B ──▶ (waits for A to finish)                        │
│                                                             │
│  Required for write-heavy caches (apt).                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
SyntaxRUN --mount=type=cache,target=/path
Syntax directive# syntax=docker/dockerfile:1
npm cache/root/.npm
pip cache/root/.cache/pip
apt cache/var/cache/apt and /var/lib/apt/lists
cargo cache/usr/local/cargo/registry and target/
Go cache/go/pkg/mod and /root/.cache/go-build
Sharing (default)shared
Sharing (apt)locked
Cache identityid=name
Non-root ownershipuid and gid
CI persistenceactions/cache + buildkit-cache-dance

Key takeaways:

  • Cache mounts persist package manager downloads across builds. The cache lives outside the image layer, so it does not bloat the final image and is not invalidated by layer cache misses.
  • The syntax directive is required. # syntax=docker/dockerfile:1 must be the first line of the Dockerfile. Without it, the --mount flag is not recognized.
  • Each package manager has a specific cache directory. npm uses /root/.npm. pip uses /root/.cache/pip. apt uses /var/cache/apt and /var/lib/apt/lists. cargo uses /usr/local/cargo/registry and target/. Mounting the wrong directory provides no benefit.
  • Apt requires sharing=locked. Concurrent apt-get update operations conflict. The locked mode serializes access, preventing corruption.
  • Remove --no-cache-dir when using a pip cache mount. The flag disables pip’s cache entirely, defeating the mount.
  • Omit the apt cleanup when using a cache mount. rm -rf /var/lib/apt/lists/* removes files that never entered the image layer. The cleanup provides no benefit and would defeat the cache if it targeted the mounted directory.
  • Cache mounts do not persist in CI automatically. GitHub Actions runners are ephemeral. Persisting cache mounts requires exporting the BuildKit cache directory with actions/cache and a tool like buildkit-cache-dance.
  • Cache key design matters in CI. Hashing the commit SHA creates a new cache entry on every push, exhausting the quota. Hashing dependency files (package-lock.json, Cargo.lock) creates new entries only when dependencies change.

Remember: Cache mounts are the difference between a build that downloads everything and a build that downloads only what changed. They are independent of the layer cache, so they help most when source changes invalidate layers frequently. Each package manager has a specific cache directory that must be mounted. apt requires sharing=locked; npm and pip work with the default. The cache never enters the image, so there is no size penalty. In local development, the cache persists automatically on the Docker daemon. In CI, it must be exported and restored explicitly. Set up the mounts, add the syntax directive, and the build will stay fast even as the source code changes.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!