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:
actions/cachewith 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 .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 thatactions/cachecan 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
| Manager | Cache Directory | Sharing | Notes |
|---|---|---|---|
| npm | /root/.npm | shared | Default |
| pip | /root/.cache/pip | shared | Remove --no-cache-dir |
| apt | /var/cache/apt and /var/lib/apt/lists | locked | Omit rm -rf cleanup |
| cargo | /usr/local/cargo/registry, /usr/local/cargo/git | shared | Plus target/ |
| Go | /go/pkg/mod, /root/.cache/go-build | shared | Module and build cache |
| Maven | /root/.m2/repository | shared | Standard pattern |
Mount Options
| Option | Purpose | Example |
|---|---|---|
target | Mount path inside container | target=/root/.npm |
id | Cache identity for sharing | id=npm-cache |
sharing | Concurrent access mode | sharing=locked |
ro | Read-only cache | ro |
uid/gid | Ownership for non-root | uid=1000,gid=1000 |
Sharing Modes
| Mode | Behavior | Use Case |
|---|---|---|
shared | Multiple builds concurrently | npm, pip, cargo |
locked | One build at a time | apt |
private | Each build gets a copy | Isolation |
Requirements
| Requirement | Notes |
|---|---|
| BuildKit | Docker 23+ default |
| Syntax directive | # syntax=docker/dockerfile:1 |
| CI persistence | actions/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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Cache mount ignored | BuildKit not enabled | DOCKER_BUILDKIT=1 or Docker 23+ |
| Pip cache not used | --no-cache-dir present | Remove the flag |
| Apt cache broken | sharing=shared | Use sharing=locked |
| Non-root can’t write cache | Missing uid/gid | Add uid=1000,gid=1000 |
| CI build still slow | Cache not persisted | Use actions/cache + export |
| Cache key exhausts quota | Hashing commit SHA | Hash dependency files |
| Cleanup defeats cache | rm -rf with apt mount | Omit 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
| Item | Value |
|---|---|
| Syntax | RUN --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 identity | id=name |
| Non-root ownership | uid and gid |
| CI persistence | actions/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:1must be the first line of the Dockerfile. Without it, the--mountflag is not recognized. - Each package manager has a specific cache directory. npm uses
/root/.npm. pip uses/root/.cache/pip. apt uses/var/cache/aptand/var/lib/apt/lists. cargo uses/usr/local/cargo/registryandtarget/. Mounting the wrong directory provides no benefit. - Apt requires
sharing=locked. Concurrentapt-get updateoperations conflict. Thelockedmode serializes access, preventing corruption. - Remove
--no-cache-dirwhen 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/cacheand a tool likebuildkit-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!