One rule explains most of the difference between a Dockerfile that rebuilds in eight seconds and one that takes a minute and a half:
Copy the dependency manifest and install dependencies before you copy your source.
Here is what it is worth, measured on the API image in this track. Same Dockerfile, same machine, three different changes:
nothing changed 0s
a file added to src/ 8s
one line added to pom.xml 92sEleven times slower, for a change to a file the compiler barely reads. Understanding why is the whole lesson.
How the cache decides
Docker walks the Dockerfile top to bottom. For each instruction it computes a key and looks for a layer already built with that key. On a hit it reuses the layer and moves on; on the first miss it rebuilds that instruction and every single one after it, hit or not.
That last part is the whole game. The cache is a prefix, not a set. One early miss invalidates everything downstream.
The key depends on the instruction:
For COPY and ADD, it is a checksum of the file contents
being copied, plus the layer below. Not the timestamp — touch pom.xml changes nothing,
which surprises people who expect make semantics.
For RUN, it is the literal command string, plus the layer below.
Docker does not run the command to see whether the result changed. RUN apt-get update
is one string forever, so it can be reused for months and hand you a stale package index — which is
why it belongs chained to the install it feeds.
The ordering, applied
The API's build stage, with the ordering that produced those numbers:
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -B dependency:go-offline || true
COPY src/ src/
RUN ./mvnw -B clean package -DskipTestsRead it as two halves. Everything above COPY src/ depends only on
pom.xml, which changes when you add a dependency — a few times a year. Everything below
depends on your source, which changes constantly.
So an ordinary code change misses at COPY src/, and the expensive dependency
resolution above it is a cache hit. That is the 8 seconds.
Write it the obvious way instead:
# Don't. Every one-character edit re-downloads the dependency tree.
COPY . .
RUN ./mvnw -B clean package -DskipTestsand every change misses at COPY . ., because the checksum of "everything" includes
your source. That is the 92 seconds, on every single build.
The same shape in Node, for exactly the same reason:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run buildnpm ci rather than npm install, incidentally: it installs exactly the
lockfile, fails loudly if package.json and the lockfile disagree, and never rewrites
the lockfile. That is the difference between a build that is reproducible and one that is merely
repeatable.
Watch the cache work
$ docker build -t pizza-api:dev .
=> [build 3/8] COPY .mvn/ .mvn/ CACHED
=> [build 4/8] COPY mvnw pom.xml ./ CACHED
=> [build 5/8] RUN ./mvnw -B dependency:go-offline || true CACHED
=> [build 6/8] COPY src/ src/ 0.1s
=> [build 7/8] RUN ./mvnw -B clean package -DskipTests 6.8sCACHED down to the first real change, then work. If you see the first
CACHED disappear on a build where you only touched source, your COPY order
is wrong.
Cache mounts, for the part ordering cannot fix
Ordering keeps the dependency layer cached while pom.xml holds still. It does
nothing for the case where pom.xml does change — you add one dependency and
Maven re-downloads all four hundred, because the layer that held them was invalidated.
BuildKit's cache mounts fix that. A mount is a directory that persists across builds and is not part of any layer:
RUN \
./mvnw -B clean package -DskipTestsRUN \
npm ciNow a changed pom.xml re-runs Maven, but Maven finds its downloads already in
~/.m2 and only fetches what is genuinely new.
Two caveats, both real. The cache is local to the builder, so a fresh CI runner
starts empty — lesson 20 covers cache-from/cache-to, which is the CI
answer. And because the mount is not a layer, nothing in it ends up in your image; that is the point,
but it means the build must still produce its output outside the mount.
Two habits that quietly destroy the cache
A version or a timestamp early in the file.
ARG BUILD_DATE
ENV BUILD_DATE=$BUILD_DATE # ← at the top: nothing below this ever caches again
COPY package.json ./
RUN npm ciThe value changes every build, so every layer after it rebuilds. Move it to the last line, where nothing depends on it.
An unbounded COPY before an expensive step. COPY . .
early is the same mistake in a different costume — and lesson 5's .dockerignore is
part of the fix, because a README that nothing reads can still bust the cache if it is inside a
COPY . ..