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.

ChangeFROM · b1 · deps · b2 · app · b3Steps rebuilt
nothing changedCCCCCC0
app.txt contents changedCCCCRR2
deps.txt contents changedCCRRRR4
touch app.txt, contents unchangedCCCCCC0
added a file no instruction copiesCCCCCC0
edited the last RUN lineCCCCCR1
edited the first RUN lineCRRRRR5
added a comment line to the DockerfileCCCCCC0
added a .dockerignoreCCCCCC0
Three things this table settles The cache key of a 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.
When the cache breaks, it breaks all the way down Compare the 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
ChangeFROM · COPY . · RUNWhich means
nothing changedCCC—
added a stray file to the directoryCRRCOPY . takes the whole directory, so the copied set changed
only touch-ed that stray fileCCCstill contents, not timestamps
added a .dockerignore excluding itCRRthe copied set changed again, so one more break
changed the stray file after excluding itCCCthe 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.dockerignoretransferring contextImage size
COPY app.js /app.jsnone37 B—
COPY app.js /app.jsexcludes node_modules27 B—
COPY . /srcnone15.73 MB19,859,207 B
COPY . /srcexcludes node_modules107 B4,125,651 B
What this means in practice If your Dockerfile copies only the files it needs, a .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.