Docker Build Caching Techniques
Last reviewed on 2026-09-25
This page is about techniques. For the rules (how Docker decides whether a step is a cache hit, what CACHED means, how to list the cache and force a rebuild), see How Docker Knows When to Use the Build Cache. The techniques below all rely on those rules.
Table of Contents
- 1. Order instructions by change frequency
- 2. Install dependencies from manifests first
- 3. Keep the build context small
- 4. Cache mounts: RUN --mount=type=cache
- 5. Bind mounts instead of COPY
- 6. Split work into stages
- 7. Share the cache in CI: --cache-from / --cache-to
- Finding the step that breaks the cache
- Checklist
1. Order instructions by change frequency
A cache miss rebuilds that step and every step after it in the stage. Put what rarely changes at the top and what changes on every commit at the bottom:
# syntax=docker/dockerfile:1
# 1. Base image: changes when you bump the version
FROM node:22-slim
# 2. System packages: change occasionally
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# 3. Dependencies: change when the lockfile changes
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 4. Source: changes on every commit
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
Keep apt-get update and apt-get install in the same RUN. A cached update layer on its own would let a later edit to the install line run against a stale package index.
2. Install dependencies from manifests first
Copy only the files that define dependencies, install, then copy the rest of the source. Editing application code then leaves the install step cached.
| Ecosystem | Copy first | Then run |
|---|---|---|
| Node.js (npm) | package.json package-lock.json | npm ci |
| Node.js (pnpm) | package.json pnpm-lock.yaml | pnpm install --frozen-lockfile |
| Python (pip) | requirements.txt | pip install -r requirements.txt |
| Python (uv) | pyproject.toml uv.lock | uv sync --frozen --no-install-project |
| Go | go.mod go.sum | go mod download |
| Java (Maven) | pom.xml | mvn dependency:go-offline |
| Rust | Cargo.toml Cargo.lock | cargo fetch |
| Ruby | Gemfile Gemfile.lock | bundle install |
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Commit lockfiles. Without one, the manifest can stay the same while the resolved versions drift, and the cached layer no longer matches what a fresh install would produce.
3. Keep the build context small
COPY . . checksums every file it copies. Any file that changes between builds, even one the app never reads, causes a miss. Exclude those files with .dockerignore:
.git
node_modules
dist
coverage
*.log
.env*
.DS_Store
Dockerfile*
.dockerignore
.git is the usual culprit: its contents change on every commit and fetch.
4. Cache mounts: RUN --mount=type=cache
Layer caching is all-or-nothing. When the lockfile changes, npm ci starts from zero and downloads every package again. A cache mount gives the command a persistent directory, such as the package manager's download cache, that survives between builds even when the layer itself is rebuilt. The directory is not part of the image.
# syntax=docker/dockerfile:1
# npm
RUN --mount=type=cache,target=/root/.npm \
npm ci
# pip (don't pass --no-cache-dir here; the cache is the point)
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# uv
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project
# Go modules and build cache
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /out/app .
# Maven
RUN --mount=type=cache,target=/root/.m2 \
mvn -B package -DskipTests
For apt, the Debian and Ubuntu images delete downloaded packages by default, so turn that off first. Use sharing=locked, because apt cannot share its cache with a concurrent build:
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-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 --no-install-recommends gcc libc6-dev
Things to know about cache mounts:
- Mount the download cache, not the output. Mounting
/app/node_modulesortarget/as a cache means those files are missing from the image, because the mount is detached after theRUN. Copy anything you need out of a cache mount into a normal path within the sameRUN. - They live on the builder. Cache mounts are not exported by
--cache-to. On ephemeral CI runners they start empty unless the runner's builder is persistent. --no-cachedoesn't clear them. Usedocker builder prune --filter type=exec.cachemount.- Options:
id=shares one cache across Dockerfiles or stages (defaults to the target path).sharing=shared|private|lockedcontrols concurrent use.uid/gid/modeset ownership when the build runs as a non-root user.
5. Bind mounts instead of COPY
A COPY creates a layer that stays in the image. When source files are only needed to produce an output, bind-mount them for the duration of one RUN instead. Nothing is copied into a layer, and the step is still cache-keyed on the mounted files:
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
RUN --mount=type=bind,source=go.mod,target=go.mod \
--mount=type=bind,source=go.sum,target=go.sum \
--mount=type=cache,target=/go/pkg/mod \
go mod download
RUN --mount=type=bind,target=. \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/app .
Bind mounts are read-only by default. Write outputs outside the mounted path (/out above).
6. Split work into stages
With BuildKit, each stage is cached independently and independent stages build in parallel. A frontend change doesn't invalidate the backend stage, and vice versa:
# syntax=docker/dockerfile:1
FROM node:22 AS frontend
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY frontend/ ./
RUN npm run build
FROM golang:1.23 AS backend
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY backend/ ./
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/api .
FROM gcr.io/distroless/static-debian12
COPY --from=backend /out/api /api
COPY --from=frontend /app/dist /public
ENTRYPOINT ["/api"]
The multi-stage builds tutorial covers --target and exporting artifacts.
7. Share the cache in CI: --cache-from / --cache-to
CI runners usually start with an empty builder, so every build is a full build unless the cache is exported somewhere and imported on the next run. BuildKit does this with --cache-to (export) and --cache-from (import).
| Backend | Where the cache goes | Typical use |
|---|---|---|
type=registry | A separate cache image in a registry, e.g. myapp:buildcache | Any CI with registry access. Supports mode=max. |
type=gha | GitHub Actions cache service | GitHub Actions. No registry needed. |
type=inline | Embedded in the pushed image itself | Simplest setup. mode=min only. |
type=local | A directory on disk | CI systems with directory caching. |
type=s3 / type=azblob | Object storage | Self-hosted runners, large caches. |
mode=min vs mode=max: min (the default) exports only the layers of the final image. max also exports every intermediate stage, such as the build stage with its compilers and dependency installs. In multi-stage builds, those intermediate stages are usually the expensive part, so use mode=max with backends that support it.
Builder driver: the default docker driver can only export inline cache, unless the containerd image store is enabled. For the other backends, create a docker-container builder first. docker/setup-buildx-action does this for you in GitHub Actions.
docker buildx create --name ci --driver docker-container --use
Registry cache
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
-t registry.example.com/myapp:$GIT_SHA \
--push .
Keep the cache under its own tag (:buildcache), separate from the images you deploy. The first run warns that the cache ref doesn't exist yet, which is expected.
GitHub Actions cache (type=gha)
name: build
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
If one workflow builds several images, give each a scope (type=gha,scope=api for both cache-from and cache-to). Otherwise they overwrite each other's cache. The GitHub Actions cache has a per-repository size limit and evicts old entries.
Inline cache
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:latest \
--cache-to type=inline \
-t registry.example.com/myapp:latest --push .
The older --build-arg BUILDKIT_INLINE_CACHE=1 does the same thing. Inline cache holds only final-image layers, so intermediate stages of a multi-stage build miss on a fresh runner. Use registry or gha with mode=max when that matters.
GitLab CI (registry cache)
build:
image: docker:27
services:
- docker:27-dind
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker buildx create --driver docker-container --use
- docker buildx build
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
--push .
Finding the step that breaks the cache
Run the build with --progress=plain. The first step without CACHED is where the cache broke, and it is usually a COPY that picks up a changed file or a changed --build-arg. The layer caching reference lists what invalidates each instruction. It also covers how to list the cache (docker buildx du), force a rebuild (--no-cache, --no-cache-filter, --pull) and clear it (docker builder prune).
Checklist
- Stable instructions first, source code last.
- Dependency manifests and lockfiles copied and installed before
COPY . .. .dockerignoreexcludes.git, local build output and dependency folders.RUN --mount=type=cacheon package-manager download caches, never on output directories.- Independent work in separate stages.
- In CI: a
docker-containerbuilder,--cache-from/--cache-towithtype=registryortype=gha, andmode=max.
Check a Dockerfile's instruction order
The optimizer flags cache-breaking patterns such as COPY . . before dependency installation.