Layers & the Build Cache
Measured on Docker Desktop 4.81.0 / Engine 29.6.1, Buildx v0.35.0-desktop.2, BuildKit v0.31.1 (macOS, linux/arm64), 2026-09-30, with --progress=plain. The example project has one tiny dependency (ms), so every absolute time is small; the pattern of CACHED lines is what to read, and on a real project the npm ci step is the one that costs tens of seconds.
How the build cache works
Section titled “How the build cache works”Docker stores build steps on disk and gives each one a cache key based on:
- The instruction text itself
- The parent step’s cache key
- (For
COPY/ADD) the checksum of the contents of the copied files
When you rebuild an image, BuildKit checks each step against the cache. If the key matches, it reuses the stored result and prints CACHED. If the key does not match (the instruction changed, or a copied file changed), that step and every step after it is rebuilt.
This is the critical rule:
Cache invalidation cascades downward. Once a step is invalidated, all subsequent steps are also rebuilt.
(“Steps” and “layers” are almost the same thing here: metadata-only instructions such as ENV or CMD are cached too, but add no filesystem layer, as the Dockerfile basics lesson proved.) The reference is Docker build cache.
Why instruction order matters
Section titled “Why instruction order matters”The project used for every measurement below (a package.json with one dependency, its lockfile, and one source file):
{ "name": "cachedemo", "version": "1.0.0", "private": true, "dependencies": { "ms": "2.1.3" }}{ "name": "cachedemo", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cachedemo", "version": "1.0.0", "dependencies": { "ms": "2.1.3" } }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" } }}console.log("v1")First, a poorly ordered Dockerfile:
# @buildFROM node:24-alpineWORKDIR /app# BAD: copies all source code firstCOPY . .# runs after COPY, so it re-runs on every code changeRUN npm ci --omit=devCMD ["node", "src/app.js"]Every time you change a single source file, COPY . . is invalidated, which also invalidates RUN npm ci. Measured: after changing src/app.js the transcript was
#6 [2/4] WORKDIR /app#6 CACHED#7 [3/4] COPY . . # re-run: the context content changed#8 [4/4] RUN npm ci --omit=dev # re-run, no CACHED#8 0.741 added 1 package, and audited 2 packages in 561msThe fix is to copy dependency manifests first, install, then copy source:
# @buildFROM node:24-alpineWORKDIR /app# only changes when dependencies changeCOPY package.json package-lock.json ./# cached as long as the manifests are unchangedRUN npm ci --omit=dev# source changes only invalidate this last COPYCOPY src/ ./src/CMD ["node", "src/app.js"]After the same one-line change to src/app.js:
#6 [2/5] WORKDIR /app#6 CACHED#7 [3/5] COPY package.json package-lock.json ./#7 CACHED#8 [4/5] RUN npm ci --omit=dev#8 CACHED # the expensive step was skipped#9 [5/5] COPY src/ ./src/ # only this step ranNow npm ci re-runs only when package.json or package-lock.json changes.
What counts as “changed”
Section titled “What counts as “changed””Cache keys for COPY come from file content, not timestamps. Two measurements on the good Dockerfile:
$ touch src/app.js # new mtime, identical bytes... [5/5] COPY src/ ./src/ CACHED # still a cache hit
$ echo 'console.log("v1") ' > src/app.js # one trailing space added... [4/5] RUN npm ci --omit=dev CACHED... [5/5] COPY src/ ./src/ # re-run, no CACHEDSo a fresh git checkout (which rewrites mtimes) does not by itself bust the cache, but one changed byte does. The cache also breaks when the RUN text changes, and --no-cache ignores it entirely (used for the “cold” builds in this course).
Seeing the cache in action
Section titled “Seeing the cache in action”Cold build (--no-cache), then the same build again without changes. The number of CACHED lines in the second run: 3 of the 4 (bad) and 4 of the 5 (good) steps; the remaining step is FROM, which is resolved rather than built. A build with nothing changed has nothing to do:
$ docker build --no-cache -f Dockerfile.good -t cachedemo . # every step runs$ docker build -f Dockerfile.good -t cachedemo .#6 [2/5] WORKDIR /app CACHED#7 [3/5] COPY package.json package-lock.json CACHED#8 [4/5] RUN npm ci --omit=dev CACHED#9 [5/5] COPY src/ ./src/ CACHEDOn a real application npm ci or pip install takes tens of seconds to minutes, which is where the ordering rule pays off; here it took 0.4-0.8 s because the dependency tree is one package. Do not extrapolate our numbers; measure your own project with time docker build.
General ordering rule
Section titled “General ordering rule”Order instructions from least frequently changing to most frequently changing:
FROM(base image — rarely changes)- System package installs (
RUN apt-get install ...) - Dependency manifests copied + installed (
COPY package.json ./+RUN npm ci) - Application source code (
COPY . .) - Build steps that depend on source (
RUN npm run build)
Cache mounts: keep the package manager’s cache between builds
Section titled “Cache mounts: keep the package manager’s cache between builds”The layer cache is all-or-nothing per step: change package.json and npm ci downloads everything again. A cache mount keeps a directory outside the image between builds, so the download cache survives even when the step itself re-runs:
# @build# syntax=docker/dockerfile:1FROM node:24-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm npm ci --omit=devCMD ["node", "-e", "console.log(require('ms')(60000))"]The image built and printed 1m (the ms package converting 60000 ms). docker history shows the step as plain RUN /bin/sh -c npm ci --omit=dev # buildkit — the cache directory is not part of the image. It is stored in the builder’s cache; list what the builder holds with docker buildx du (that output is host-wide, so it is not reproduced here). We did not measure a speed-up on this one-package project; the documented benefit is for large dependency trees (cache mounts).
Hands-on: observe the cache
Section titled “Hands-on: observe the cache”The snippet below builds a tiny image twice. Run it and watch the second build use CACHED layers.
# syntax=docker/dockerfile:1
FROM node:24-alpine
WORKDIR /app
# Step 1: write a manifest and install
RUN echo '{"name":"demo","version":"1.0.0"}' > package.json
RUN npm install --omit=dev 2>/dev/null || true
# Step 2: write "source" (simulated with an echo)
RUN echo "console.log('Hello from app v1');" > app.js
CMD ["node", "app.js"]
# --- first build ---
# docker build -t cachetest .
# docker run --rm cachetest
# --- change the last RUN (v1 -> v2) and rebuild: only that step reruns ---
# docker build -t cachetest .Reading AI-generated code
Section titled “Reading AI-generated code”An AI-written Dockerfile with a helpful comment on every line:
🐛 Spot the bug
FROM alpine:3.24WORKDIR /appCOPY src/ ./src/ # copy the application sourceCMD ["ls", "src"]Show the answer
Dockerfile comments are only recognised at the start of a line. On a COPY line, everything after the destination is more arguments, so this is COPY src/ ./src/ # copy the application source with sources src/, ./src/, #, copy, the, application and destination source. The build fails, with an error about a file called the:
#7 ERROR: failed to calculate checksum of ref …: "/the": not foundERROR: failed to build: failed to solve: failed to compute cache key: … "/the": not foundThe trap: docker buildx build --check — the built-in linter — reported Check complete, no warnings found for this file. The real diagnosis came from hadolint (which the course harness runs for this reason): DL3021 error: COPY with more than 2 arguments requires the last argument to end with /. Put comments on their own line:
# @buildFROM alpine:3.24WORKDIR /app# copy the application sourceCOPY src/ ./src/CMD ["ls", "src"]The same rule holds for RUN: a trailing # comment becomes part of the shell command (harmless there, since the shell treats it as a comment, but not in COPY, ENV or EXPOSE).
Mental Model
Section titled “Mental Model”A build is a chain of memoised functions: each step is keyed by (parent result, instruction, input file contents). Change any input and that step and everything downstream must be recomputed; everything upstream is reused. Ordering a Dockerfile is deciding which inputs are allowed to invalidate which work.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
COPY split by group (manifest first, then source) | Precise cache, maximum reuse (the npm ci step stayed CACHED) | Have to write the Dockerfile more carefully about instruction order |
COPY . . all at once | Simple to write, short Dockerfile | Cache breaks easily — every code change forced npm ci to re-run |
--no-cache | Guarantees a clean, from-scratch build | Throws away all reuse; slow |
| Cache mounts | Package downloads survive across builds | Needs BuildKit; cache lives on the builder, not in CI unless exported |
Common mistakes
Section titled “Common mistakes”COPY . .for the whole project beforeRUN npm ci, breaking the cache every time code changes even though dependencies stayed the same.- Having no
.dockerignore, so the build context’s checksum changes more often than it needs to (a changed.gitor log file bustsCOPY . .). - Putting frequently-changing instructions (like copying source) near the top, breaking the cache for layers below that should have stayed stable.
- Trailing
# commentsafterCOPYarguments. - Believing
--checkpassing means the Dockerfile builds.
Common Misconceptions
Section titled “Common Misconceptions”- “Every Dockerfile instruction creates a layer.” Every instruction is a cacheable step, but only
RUN,COPYandADD(andWORKDIRcreating a directory) add filesystem layers. - “A file’s timestamp decides whether
COPYis cached.” Content does:touchleftCOPYatCACHED; one added space rebuilt it. - “
CACHEDmeans downloaded from somewhere.” It means a local step result was reused (unless you configured a remote cache). - “The cache is invalidated only for the changed step.” Everything after it re-runs too.