Skip to content

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.

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.

The project used for every measurement below (a package.json with one dependency, its lockfile, and one source file):

package.json
{
"name": "cachedemo",
"version": "1.0.0",
"private": true,
"dependencies": { "ms": "2.1.3" }
}
package-lock.json
{
"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"
}
}
}
src/app.js
console.log("v1")

First, a poorly ordered Dockerfile:

Dockerfile.bad
# @build
FROM node:24-alpine
WORKDIR /app
# BAD: copies all source code first
COPY . .
# runs after COPY, so it re-runs on every code change
RUN npm ci --omit=dev
CMD ["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 561ms

The fix is to copy dependency manifests first, install, then copy source:

Dockerfile.good
# @build
FROM node:24-alpine
WORKDIR /app
# only changes when dependencies change
COPY package.json package-lock.json ./
# cached as long as the manifests are unchanged
RUN npm ci --omit=dev
# source changes only invalidate this last COPY
COPY 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 ran

Now npm ci re-runs only when package.json or package-lock.json changes.

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 CACHED

So 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).

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/ CACHED

On 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.

Order instructions from least frequently changing to most frequently changing:

  1. FROM (base image — rarely changes)
  2. System package installs (RUN apt-get install ...)
  3. Dependency manifests copied + installed (COPY package.json ./ + RUN npm ci)
  4. Application source code (COPY . .)
  5. 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:

Dockerfile.mount
# @build
# syntax=docker/dockerfile:1
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
CMD ["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).

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 .

An AI-written Dockerfile with a helpful comment on every line:

🐛 Spot the bug

FROM alpine:3.24
WORKDIR /app
COPY src/ ./src/ # copy the application source
CMD ["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 found
ERROR: failed to build: failed to solve: failed to compute cache key: … "/the": not found

The 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:

Dockerfile.comment
# @build
FROM alpine:3.24
WORKDIR /app
# copy the application source
COPY 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).

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.

OptionBenefitCost
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 onceSimple to write, short DockerfileCache breaks easily — every code change forced npm ci to re-run
--no-cacheGuarantees a clean, from-scratch buildThrows away all reuse; slow
Cache mountsPackage downloads survive across buildsNeeds BuildKit; cache lives on the builder, not in CI unless exported
  • COPY . . for the whole project before RUN 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 .git or log file busts COPY . .).
  • Putting frequently-changing instructions (like copying source) near the top, breaking the cache for layers below that should have stayed stable.
  • Trailing # comments after COPY arguments.
  • Believing --check passing means the Dockerfile builds.
  • “Every Dockerfile instruction creates a layer.” Every instruction is a cacheable step, but only RUN, COPY and ADD (and WORKDIR creating a directory) add filesystem layers.
  • “A file’s timestamp decides whether COPY is cached.” Content does: touch left COPY at CACHED; one added space rebuilt it.
  • “CACHED means 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.
What happens to all steps below an invalidated step?
Why do we COPY package.json before COPY . . in a Node.js Dockerfile?
Which of these instructions creates a new filesystem layer in the image?
A second docker build on unchanged source prints CACHED for most steps. What does that mean?
You run touch src/app.js (no content change) and rebuild. What happens to COPY src/ ./src/?
COPY src/ ./src/ # copy the source passes docker buildx build --check but the build fails with "/the": not found. Why?