Skip to content
Blog

Docker Build Cache: What Invalidates Each Layer

Why changing one line of code re-downloads your dependencies — the exact rules Docker uses to hit or miss the cache, and how to structure a Dockerfile so it hits almost every time.

Published on • October 14, 2026

AI Assistant

A build that takes nine minutes the first time and forty seconds afterwards is the cache working. A build that takes nine minutes every time is the cache being invalidated — and the message almost never tells you which line did it.

Docker’s cache rules are simple enough to hold in your head, and once you do, Dockerfile structure stops being a matter of taste.

The mental model: a stack of instructions

Each instruction in a Dockerfile produces a layer, and layers stack. When you build, the builder walks the instructions in order and, for each one, asks: have I seen this before? If yes, it reuses the cached layer and moves on. If no, the cache is invalidated for that instruction and every instruction after it — even ones whose output would be identical.

FROM node:22-slim          # 1. base image — checked against local cache
WORKDIR /app               # 2. instruction match
COPY package*.json ./      # 3. checksum of file metadata
RUN npm ci                 # 4. instruction match → cache hit
COPY . .                   # 5. checksum changed (you edited src/) → MISS
RUN npm run build          # 6. must re-run
CMD ["node", "dist/main.js"]  # 7. must re-run

That cascade is the whole game. Editing src/app.ts invalidates the COPY . . layer, and everything below it rebuilds — including steps that would have produced byte-identical output. Ordering is therefore not a style question: put instructions that change rarely at the top and instructions that change constantly at the bottom.

The exact lookup rules

Docker’s cache-invalidation documentation spells out the mechanics:

  • Base image first. The builder checks whether the base image is cached, then compares each subsequent instruction against cached layers. No exact match → invalidation.
  • COPY and ADD use a checksum of file metadata. The cache key derives from the files involved, not just the command string — so COPY src/ ./ invalidates when a file under src/ changes. Modification time (mtime) is deliberately excluded from the checksum, so touching a file without changing it does not bust the cache.
  • RUN --mount=type=bind behaves like COPY — its bind-mounted inputs participate in the checksum.
  • For everything else — RUN especially — only the command string is compared. The builder does not look inside the container at the result. RUN apt-get update is a cache hit purely because the string matches, regardless of what the internet has done since your last build.

That last rule explains the two classic traps.

Trap 1: the stale package index

# Broken: separate RUN statements
FROM ubuntu:24.04
RUN apt-get update            # cached from three weeks ago
RUN apt-get install -y curl   # uses the stale index; may install old or fail

The second build reuses the cached apt-get update — so you install against a package index from whenever that layer was last built. Fix: combine them, so any change to the package list invalidates the whole step:

RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    && rm -rf /var/lib/apt/lists/*

Cleaning /var/lib/apt/lists in the same layer also keeps the index out of the image — removing it in a later RUN would shrink nothing, because the earlier layer still contains it.

Trap 2: npm install that never re-runs

COPY . .            # your source code changes here
RUN npm ci          # too late — this only re-runs when COPY . . misses

If package.json changes, COPY . . has already invalidated everything, so npm ci re-runs anyway — fine. The real problem is the inverse: dependency installs should depend only on lockfiles, so that code edits don’t trigger a full reinstall:

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Now editing source rebuilds from step 4 onward and keeps the dependency layer cached. The same shape applies to requirements.txt + pip, Cargo.toml/Cargo.lock, and go.mod.

Forcing a rebuild when you need one

Cached layers are honest but stale. Three tools, three different problems:

ToolWhat it doesUse when
--pullChecks for and downloads a newer base imageThe base tag moved (security patch)
--no-cacheDisables the cache; re-runs all stepsA RUN step must see fresh internet state
--no-cache-filter <stage>Invalidates cache for one stageOnly the install stage needs re-running
docker builder pruneRemoves cached layers from the storeDisk pressure, or a clean slate before a release build

Note --pull and --no-cache solve different problems and are frequently combined: docker build --pull --no-cache . gives you a fresh base and re-executed steps.

For a deliberately broken cache, there’s the classic one-liner:

ARG CACHEBUST=1
RUN apt-get update && apt-get install -y curl
$ docker build --build-arg CACHEBUST=$(date +%s) .

Changing an ARG value changes the instruction’s cache key. Because ARG values participate in invalidation, this doubles as your “secret changed, force re-run” escape hatch — see below.

SOURCE_DATE_EPOCH deserves a mention: WORKDIR respects it for cache validity, so feeding it a Git commit timestamp invalidates the cache on every commit. That’s correct if you’re tracking provenance — and a footgun if you just wanted reproducible timestamps.

Secrets and the cache

The contents of a build secret are not part of the build cache — changing a secret’s value does not invalidate anything. Two consequences:

  1. Rotating a token does not force a rebuild of the step that used it (usually what you want — no gratuitous cache busts).
  2. If a step genuinely depends on the secret’s value, pair it with a changing ARG (from CI, e.g. --build-arg CACHEBUST=$DEPLOY_REVISION).

What does participate in the checksum: the secret’s ID and mount path. Rename the mount and the layer rebuilds.

Cache in CI

Local caches don’t exist in ephemeral CI runners, so you export and import them:

- uses: docker/build-push-action@v6
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha stores cache in GitHub Actions’ cache backend; type=registry pushes cache layers to your registry alongside the image; type=local uses a directory restored from CI cache. mode=max exports all layers, not just the final stage’s — slower to export, much better hit rates for multi-stage builds.

Two CI-specific pitfalls:

  • Cache keys must be stable across runs. Build args that embed timestamps or commit SHAs (unless you want that) destroy hit rates.
  • --no-cache in CI “to be safe” means every build pays full price forever. It belongs in a scheduled rebuild, not the PR pipeline.

BuildKit’s cache mounts solve a different problem — persistent in-package-manager caches that survive across builds and are shared between builds without being part of the image layers:

RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/var/cache/apk apk add --no-cache git

Unlike COPY, the contents of a cache mount don’t invalidate the layer — the layer’s cache key comes from the instruction — but the mount gives npm/pip/apk a warm download cache, cutting cold installs dramatically.

Debugging: see what’s actually happening

$ BUILDKIT_PROGRESS=plain docker build .

Plain progress prints cache hits and misses per step (CACHED vs elapsed time), which is usually enough to spot the offending line. For deeper inspection, docker history <image> shows layer sizes and the command that produced them, and docker buildx du reports cache store usage.

A quick audit heuristic: if your build re-runs apt-get/npm ci/pip install on a code-only change, your dependency layers are ordered below the code COPY. Reorder and watch the build drop back to seconds.

Checklist

  • Order: base → system deps → dependencies → source → build → runtime config.
  • Copy lockfiles (not the whole tree) before installing dependencies.
  • Combine apt-get update && install — and clean the lists — in one RUN.
  • Remember: RUN keys on the string, COPY keys on file content (mtime excluded).
  • Use --pull for fresh bases, --no-cache for fresh internet state, cache mounts for package managers.
  • Pair secret rotation with a changing ARG when a step depends on the value.
  • Export/import cache in CI (type=gha, type=registry); reserve --no-cache for scheduled rebuilds.
  • Debug with BUILDKIT_PROGRESS=plain and docker history.

Sources: Docker — build cache, Docker — cache invalidation, Docker — building best practices, Dockerfile best practices (Docker blog).