Docker – Layer Caching and Build Speed

June 30, 20264 min readUpdated 8/21/2026

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       92s

Eleven 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 -DskipTests

Read 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 -DskipTests

and 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 build

npm 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.8s

CACHED 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 --mount=type=cache,target=/root/.m2 \
    ./mvnw -B clean package -DskipTests
RUN --mount=type=cache,target=/root/.npm \
    npm ci

Now 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 ci

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

Next: building in one image and shipping from another.