Lesson 1
What breaks the build cache
Docker reuses a step's result when that step's inputs have not changed. Simple enough to say, but what counts as an input has to be measured: file contents or also modification times, the whole directory or only the copied files, and whether a comment line counts at all. This lesson measures fourteen cases, each changing exactly one thing.
Nine scenarios on one Dockerfile
FROM alpine:3.22
RUN echo "step-1" > /b1
COPY deps.txt /deps.txt
RUN echo "step-2" > /b2
COPY app.txt /app.txt
RUN echo "step-3" > /b3
Each row below is one complete build after the cache had been warmed. The result column reads in the order of the six steps above: C is CACHED, R is a rebuild.
| Change | FROM · b1 · deps · b2 · app · b3 | Steps rebuilt |
|---|---|---|
| nothing changed | CCCCCC | 0 |
app.txt contents changed | CCCCRR | 2 |
deps.txt contents changed | CCRRRR | 4 |
touch app.txt, contents unchanged | CCCCCC | 0 |
| added a file no instruction copies | CCCCCC | 0 |
edited the last RUN line | CCCCCR | 1 |
edited the first RUN line | CRRRRR | 5 |
| added a comment line to the Dockerfile | CCCCCC | 0 |
added a .dockerignore | CCCCCC | 0 |
COPY is the file's contents, not its modification time —
the touch row stays CACHED. COPY app.txt looks at that file and nothing
else, so adding other files to the directory changes nothing. And comment lines are not part of
the cache key.
deps.txt and app.txt rows: the same operation — change one
file's contents — costs four rebuilt steps when the file is copied early and two when it is copied
late. RUN echo "step-2" never reads deps.txt and has nothing to do with
it, yet it runs again, because Docker's cache is a chain and losing one link loses everything
after it.
One note on reading the log: the [1/6] FROM … line appears even when nothing changed.
That is BuildKit re-resolving the base image reference, not rebuilding a layer. The table above
counts FROM as CACHED in every row except the one that edits that very line.
Copying one file is not copying a directory
A second, shorter Dockerfile isolates the behaviour of COPY .:
FROM alpine:3.22
COPY . /src
RUN echo "step-1" > /b1
| Change | FROM · COPY . · RUN | Which means |
|---|---|---|
| nothing changed | CCC | — |
| added a stray file to the directory | CRR | COPY . takes the whole directory, so the copied set changed |
only touch-ed that stray file | CCC | still contents, not timestamps |
added a .dockerignore excluding it | CRR | the copied set changed again, so one more break |
| changed the stray file after excluding it | CCC | the file is no longer part of the copied set |
Those last two rows are the argument for adding a .dockerignore early: adding it
breaks the cache once, and from then on every change inside node_modules,
.git or dist stops touching the build at all.
BuildKit only sends the files it needs
A belief left over from the old builder: without a .dockerignore, Docker tars the
whole directory and ships it to the daemon. With BuildKit that is no longer true. Measured on a
15 MB directory (node_modules/blob.bin at 15 MB plus a few bytes of
app.js):
| Dockerfile | .dockerignore | transferring context | Image size |
|---|---|---|---|
COPY app.js /app.js | none | 37 B | — |
COPY app.js /app.js | excludes node_modules | 27 B | — |
COPY . /src | none | 15.73 MB | 19,859,207 B |
COPY . /src | excludes node_modules | 107 B | 4,125,651 B |
.dockerignore barely changes how
much data is shipped — 37 bytes against 27. It becomes important as soon as there is a
COPY ., and there it is very important indeed: 15.73 MB down to 107 bytes, and the
image from 19.9 MB down to 4.1 MB.
The lab
Type your own Dockerfile, declare what you just changed, and watch each step. The algorithm running in your browser was checked against 14 real builds on Docker 29.7.2 — all of them agree.
Dockerfile
What you just changed
The measured scenarios:
Each step on the next build
What follows: the order of your lines
The first table says the cost of a change equals the number of steps after the one that broke. So the order of the lines is a performance decision, not an aesthetic one. The rule that falls out of the measurements: put what rarely changes at the top and what changes often at the bottom.
# the common ordering, and the expensive one
COPY . /app
RUN npm ci
RUN npm run build
# the cheaper one: the lock file changes less often than the source
COPY package.json package-lock.json /app/
RUN npm ci
COPY . /app
RUN npm run build
In the first version, editing any source line makes npm ci run again. In the second,
npm ci only runs again when package-lock.json changes — exactly the
mechanism measured in the deps.txt against app.txt rows.
Check yourself
A Dockerfile has 10 steps and you edit line 3. How many steps rebuild?
Eight: line 3 and the seven after it. Docker's cache is a chain, so the cost of a change is the number of steps that follow it, whether or not they have anything to do with the change.A git checkout changes the mtime of every file, but most contents are unchanged. Is the cache still valid?
Yes, for every file whose contents did not change. The cache key of a COPY is the contents; the "only touched" row in the measured table comes back CACHED. The pre-BuildKit builder was more sensitive to metadata, which is why the older advice says the opposite.
Your Dockerfile has only COPY package.json . and COPY src/ ./src. Will a .dockerignore make the build faster?
Barely, in terms of data shipped: 37 bytes against 27 for the per-file COPY case. It matters when the Dockerfile has a COPY ., where the measurement is 15.73 MB down to 107 bytes.
Why does adding a .dockerignore break the cache once?
Because the set of files COPY . copies has just changed, so that step's input changed with it. It is a single break; from the next build on, changes inside the excluded directories no longer touch the cache at all.