Skip to content

04 · Image Layering & Build Cache

Every instruction in a Dockerfile that changes the filesystem (RUN, COPY, ADD) produces a new, independent, content-addressed layer. Docker caches each layer and reuses it on later builds when nothing that could affect it has changed — which is why instruction order has a direct, measurable effect on build speed.

The cache invalidation rule

For each instruction, the builder checks whether it can reuse a previously built layer:

  • For RUN, the cache key is the exact command string plus the parent layer's identity — same command, same starting point, same cached result reused.
  • For COPY/ADD, the cache key additionally includes a checksum of the files being copied — if even one byte of a copied file changed, that layer (and every layer after it) is rebuilt.

The critical consequence: once one layer misses the cache, every subsequent layer misses too, even if their own inputs didn't change, because their "starting point" (the previous layer) is now different.

Ordering for maximum cache reuse

FROM python:3.12-slim
WORKDIR /app

# Copy ONLY the dependency manifest first
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Application source changes far more often than dependencies —
# putting COPY . . last means edits to app code never invalidate
# the (usually slow) pip install layer above it.
COPY . .

CMD ["python", "app.py"]

Compare to the naive ordering:

COPY . .                                    # any code change invalidates everything below
RUN pip install --no-cache-dir -r requirements.txt   # reinstalled on every code edit

Worked example: measuring the effect

# First build: everything is cold
time docker build -t myapp .

# Edit only app.py, leave requirements.txt untouched
docker build -t myapp .
# With the good ordering: pip install layer says "CACHED", build takes ~1s
# With COPY . . first: pip install reruns from scratch, taking as long as build #1

BuildKit cache mounts for package managers

Even with good layer ordering, a changed requirements.txt still re-downloads every package from scratch by default. A cache mount persists a directory (like pip's or npm's download cache) across builds, independent of the layer cache:

# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

The --mount=type=cache directory is not committed as part of the image layer — it's a persistent cache directory reused across separate builds (even ones that would otherwise be cache misses), separate from the layer cache that keys off the Dockerfile instruction and input hash.

How It Actually Works

Layers are content-addressed, and the cache key chains through history. Each layer's ID is a hash of its own diff content combined with its parent layer's ID — so a layer's identity mechanically encodes "the entire sequence of instructions that produced it," not just its own diff. When BuildKit decides whether a RUN can reuse cache, it hashes the instruction text plus that chained parent ID; if the parent ID differs from last time (because an earlier COPY produced different content), the hash can't match anything in the cache store even though the RUN command's text is identical — this is the mechanical reason a single upstream cache miss cascades forward through the rest of the Dockerfile.

Why --mount=type=cache survives what the layer cache doesn't. BuildKit implements cache mounts as named, persistent directories on the build host (or a shared cache backend), mounted into the ephemeral build container for the duration of that one RUN instruction only, then unmounted — so their contents never become part of any layer's diff and are never hashed into the layer's content-addressed ID. This decouples "did the layer cache hit" from "is the package-manager download cache warm": a requirements.txt change always invalidates the RUN pip install layer (because COPY requirements.txt .'s content hash changed), but the cache-mounted /root/.cache/pip directory is untouched by that invalidation and still has the previously downloaded wheel files sitting on disk, so pip only needs to fetch genuinely new/changed packages instead of everything.

Exercise

Take a Dockerfile that does COPY . . before RUN npm install, and rewrite it to copy package.json/package-lock.json first, run npm ci with a --mount=type=cache,target=/root/.npm, then copy the rest of the source. Build twice, editing only a source file (not package.json) between builds, and confirm via the build output that the npm ci layer shows CACHED on the second build.