Dockerfile mechanics: which instructions become layers, how the cache keys each step, and why order matters
Each Dockerfile instruction is either a filesystem layer (RUN, COPY, ADD) or a zero-byte config mutation (ENV, WORKDIR, CMD, …). The cache keys each step on parent state plus instruction text plus, for COPY/ADD, a file checksum. The first changed step busts every layer below it.
The CI dashboard said the Node service took 94 seconds to rebuild after a one-line change to a route handler. Nobody touched package.json. Nobody added a dependency. Yet every push reran npm ci from scratch, pulling 1,100 packages, every single time. A junior asked the obvious question: “why does editing one .ts file reinstall node_modules?” The Dockerfile held the answer, and it was the instruction order. The file did COPY . . and then RUN npm ci. Because COPY . . copies the whole working tree, its cache key includes a checksum of every file — so the moment one source byte changed, that layer’s key changed, and the cache invalidation cascaded down: every instruction below COPY, including npm ci, was forced to rebuild. The fix was three lines and zero new tools: copy package.json and the lockfile first, run the install, then copy the source. After that a code-only edit hit the install cache every time. Rebuild dropped from 94 seconds to about 3. The lesson was not “npm is slow.” The lesson was that a Dockerfile is an ordered cache, and where you put COPY is a performance decision.
Reading a Dockerfile as an ordered, content-addressed cache — not as a run-once script — explains every slow rebuild before you even touch the timer.
Two kinds of instruction: layers vs config
Walk a Dockerfile top to bottom and every instruction does one of two things. A few of them — RUN, COPY, ADD — execute against the filesystem and produce a new read-only layer: a tarball of exactly the files that changed, named by a content digest. Everything else — ENV, WORKDIR, EXPOSE, ENTRYPOINT, CMD, LABEL, USER, ARG — touches no filesystem at all. It writes a field into the image’s JSON config and records a zero-byte history entry. Run docker history and you can see the split directly:
$ docker history myapp:1 --format '{{.Size}}\t{{.CreatedBy}}'
0B CMD ["node" "server.js"]
0B EXPOSE 3000
0B ENV NODE_ENV=production
0B WORKDIR /app
12.4MB COPY . . # source
108MB RUN npm ci
1.1kB COPY package.json package-lock.json ./
0B WORKDIR /app
77.8MB RUN apt-get install ...The 0B rows are the config mutations: they change the image (and therefore the image ID) but add no layer bytes. The sized rows are the real filesystem layers. This is not trivia — it tells you exactly which lines are expensive to rebuild and which are free.
A Dockerfile ends with four lines: WORKDIR /app, ENV NODE_ENV=production, EXPOSE 3000, and a CMD running node server.js. How many filesystem layers do these four lines add, and what do they change?
The cache key of one step
Now the part that decides your rebuild time. When BuildKit considers reusing a cached layer for a step, it computes a cache key and looks for a match. The key for a step is, roughly: the digest of the parent layer (the entire image state so far) plus the literal instruction text — and, for COPY and ADD only, a checksum of the actual file contents being copied. So RUN npm ci is keyed purely on the text RUN npm ci and whatever state sits beneath it; change neither and it hits the cache. But COPY . . is keyed on a checksum of every copied file, so changing one byte of one source file changes its key and forces a miss.
# every step's key includes everything above it
FROM node:20-slim # parent base
WORKDIR /app # config only, free
COPY package.json package-lock.json ./ # key = checksum(these 2 files)
RUN npm ci # key = text "RUN npm ci" + state above
COPY . . # key = checksum(whole source tree)
CMD ["node","server.js"] # config only, freeThe consequence is a one-way cascade: the first step whose key changes invalidates every step below it. Cache is a prefix match from the top. If COPY package.json still matches but COPY . . does not, only COPY . . and what follows rebuild — npm ci above it stays cached. Flip the order so COPY . . comes first and the cascade reaches npm ci on every code edit. That is the entire war-story in one rule.
▸Why this works
Why does COPY get a content checksum while RUN does not? Because Docker cannot know what a RUN command will produce — it cannot run it to find out cheaply — so it conservatively trusts the instruction text: same text, same parent, assume same result. COPY is different: its inputs are files on your build context, and those are cheap to checksum, so BuildKit can be precise. This asymmetry is also the classic footgun. RUN apt-get update keyed on text alone will happily serve a months-old package index from cache, because the text never changed even though the upstream repo did. The text-only cache is a feature for reproducibility and a trap for freshness, and knowing which instructions are text-keyed tells you exactly where stale cache can bite.
RUN-chaining and the rm-in-a-later-layer trap
Because each RUN is its own layer, a file you create in one RUN and delete in a later RUN does not shrink the image. Layers are stacked and immutable: the lower layer still carries the bytes, and the delete only writes a whiteout in the higher layer that hides the file at runtime. The bytes are still pulled, still stored, still extractable.
# WRONG: two layers — clean happens too late to shrink anything
RUN apt-get update && apt-get install -y build-essential
RUN apt-get clean && rm -rf /var/lib/apt/lists/* # whiteout only
# RIGHT: one layer — the index and caches never persist into the layer
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*Chaining the install and the cleanup into one RUN means the apt package index (often 40–60 MB) and downloaded .deb caches are removed within the same layer, before its tarball is finalized, so they never land in the image at all. On a typical Debian build this single change saves roughly 40 to 100 MB, and --no-install-recommends trims another tranche of optional packages. The rule generalizes: anything you create only to delete must be created and deleted inside one RUN.
A Dockerfile downloads a 200 MB build tool in one RUN, uses it, then deletes it in a separate later RUN. What is the image-size effect?
Busting the cache when you mean to
Sometimes the text-keyed cache is too sticky and you need a deliberate miss — for example to force a fresh git clone or a re-fetch. The clean lever is an ARG whose value you pass at build time: referencing it inside a RUN folds its value into that step’s cache key, so changing the arg busts from that point down. A content checksum (passing a known file hash as a build arg) does the same precisely. The blunt instrument is docker build --no-cache, which skips the cache for every step. And to share cache across machines or a fresh CI runner, docker build --cache-from seeds the local cache from a pushed image so a cold runner still hits warm layers. One more thing worth a single line: BuildKit is the default builder now, and it parallelizes independent stages of a multi-stage build rather than running them strictly top to bottom — the deep dive on multi-stage builds is the next unit.
- 01Which instructions create a filesystem layer and which only mutate the config, and what is each step's cache key?
- 02Explain the cache-invalidation cascade and how it makes instruction order a performance contract, plus the rm-in-a-later-layer trap.
A Dockerfile is not a script that runs once; it is an ordered, content-addressed cache, and reading it that way explains every rebuild surprise. Each instruction does one of two things. RUN, COPY and ADD execute against the filesystem and freeze a new read-only layer — a tarball named by a content digest. Everything else — ENV, WORKDIR, EXPOSE, ENTRYPOINT, CMD, LABEL, USER, ARG — writes a field into the JSON config and records a zero-byte history entry; it changes the image ID but adds no layer bytes, which docker history shows directly as a column of 0B rows above the sized ones. The cache decides your rebuild time. A step’s key is the parent state plus the instruction text, and for COPY and ADD additionally a checksum of the files copied; RUN is keyed on text alone, so a text-identical RUN apt-get update can hand you a stale package index. Because cache is a prefix match, the first step whose key changes invalidates every step below it — which is why order is a performance contract. The canonical fix is to copy the lockfile and install dependencies low, then copy source high: a code-only edit then misses on the top COPY but keeps npm ci cached, turning a 94-second reinstall into about 3 seconds and pushing the install cache-hit rate from near zero to near one. Two more rules follow from immutability: a file deleted in a later RUN still ships in the lower layer under a whiteout, so chain create-and-clean in one RUN to actually save the ~40-100 MB; and when you genuinely need a fresh result, bust the text cache on purpose with an ARG or a checksum, blank it with —no-cache, or warm a cold runner with —cache-from. BuildKit, the default builder, also parallelizes independent stages — but that is the next unit. Hold the model: layers are bytes, config is identity, the cache is a prefix, and order is the lever. Now when you see a CI rebuild take 90 seconds on a code-only change, the first thing you check is where COPY . . sits relative to RUN npm ci — order is almost always the culprit.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.