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.
COPYandADDuse a checksum of file metadata. The cache key derives from the files involved, not just the command string — soCOPY src/ ./invalidates when a file undersrc/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=bindbehaves likeCOPY— its bind-mounted inputs participate in the checksum.- For everything else —
RUNespecially — only the command string is compared. The builder does not look inside the container at the result.RUN apt-get updateis 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:
| Tool | What it does | Use when |
|---|---|---|
--pull | Checks for and downloads a newer base image | The base tag moved (security patch) |
--no-cache | Disables the cache; re-runs all steps | A RUN step must see fresh internet state |
--no-cache-filter <stage> | Invalidates cache for one stage | Only the install stage needs re-running |
docker builder prune | Removes cached layers from the store | Disk 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:
- Rotating a token does not force a rebuild of the step that used it (usually what you want — no gratuitous cache busts).
- 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-cachein 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 oneRUN. - Remember:
RUNkeys on the string,COPYkeys on file content (mtimeexcluded). - Use
--pullfor fresh bases,--no-cachefor fresh internet state, cache mounts for package managers. - Pair secret rotation with a changing
ARGwhen a step depends on the value. - Export/import cache in CI (
type=gha,type=registry); reserve--no-cachefor scheduled rebuilds. - Debug with
BUILDKIT_PROGRESS=plainanddocker history.
Sources: Docker — build cache, Docker — cache invalidation, Docker — building best practices, Dockerfile best practices (Docker blog).