Docker Build Caching Techniques

Last reviewed on 2026-09-25

15 min read Intermediate Updated: Sep 25, 2026

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.

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.

EcosystemCopy firstThen run
Node.js (npm)package.json package-lock.jsonnpm ci
Node.js (pnpm)package.json pnpm-lock.yamlpnpm install --frozen-lockfile
Python (pip)requirements.txtpip install -r requirements.txt
Python (uv)pyproject.toml uv.lockuv sync --frozen --no-install-project
Gogo.mod go.sumgo mod download
Java (Maven)pom.xmlmvn dependency:go-offline
RustCargo.toml Cargo.lockcargo fetch
RubyGemfile Gemfile.lockbundle 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_modules or target/ as a cache means those files are missing from the image, because the mount is detached after the RUN. Copy anything you need out of a cache mount into a normal path within the same RUN.
  • 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-cache doesn't clear them. Use docker builder prune --filter type=exec.cachemount.
  • Options: id= shares one cache across Dockerfiles or stages (defaults to the target path). sharing=shared|private|locked controls concurrent use. uid/gid/mode set 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).

BackendWhere the cache goesTypical use
type=registryA separate cache image in a registry, e.g. myapp:buildcacheAny CI with registry access. Supports mode=max.
type=ghaGitHub Actions cache serviceGitHub Actions. No registry needed.
type=inlineEmbedded in the pushed image itselfSimplest setup. mode=min only.
type=localA directory on diskCI systems with directory caching.
type=s3 / type=azblobObject storageSelf-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 . ..
  • .dockerignore excludes .git, local build output and dependency folders.
  • RUN --mount=type=cache on package-manager download caches, never on output directories.
  • Independent work in separate stages.
  • In CI: a docker-container builder, --cache-from/--cache-to with type=registry or type=gha, and mode=max.

Check a Dockerfile's instruction order

The optimizer flags cache-breaking patterns such as COPY . . before dependency installation.

Dockerfile Optimizer