Most teams write a Dockerfile once, watch it build, and never think about it again until CI starts taking twelve minutes for a one-line change. The culprit is almost always cache invalidation: a single misplaced instruction near the top of the file that busts every layer below it on every build. Understanding how the layer cache actually decides what to reuse is the difference between a two-second incremental build and a full rebuild from scratch.
How the cache key is computed
Each instruction in a Dockerfile produces a layer, and Docker checks whether it can reuse a cached layer by hashing the instruction plus its inputs. For RUN, that's the command string itself. For COPY and ADD, it's the checksum of the files being copied, not just the command text. The moment one layer misses the cache, every subsequent layer rebuilds too, even if nothing downstream actually changed.
This is why dependency installation should happen before application code is copied in:
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run buildBecause package-lock.json rarely changes between commits, the npm ci layer stays cached across most builds, and only the final COPY . . and build step pay the cost of the actual code change.
Ordering instructions by volatility
A good rule of thumb: order Dockerfile instructions from least to most frequently changing. OS packages change rarely, so they go first. Application dependencies change occasionally. Source code changes constantly, so it goes last. Violate that order and you turn a stable base into a moving target:
# Bad: source copied before dependency install
COPY . .
RUN pip install -r requirements.txt
# Better: dependency manifest copied separately
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .The "bad" version invalidates the pip install on every single code change, since COPY . . includes the source tree that changes constantly.
Multi-stage builds for smaller, cleaner layers
Multi-stage builds don't just shrink the final image, they also isolate cache-breaking operations from the runtime stage. Compilers, build tools, and test dependencies live in an early stage and never touch the final image:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]The distroless final stage has no shell, no package manager, and a tiny attack surface, while the builder stage retains full caching benefits on its own.
Using BuildKit's cache mounts
Package managers like apt, npm, and go mod maintain their own local cache directories that get wiped between builds unless you persist them explicitly. BuildKit's --mount=type=cache solves this without polluting the image layer itself:
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go build -o /app ./cmd/serverThat cache persists across builds on the same builder instance, independent of the layer cache, which matters a lot for languages with slow, incremental compilers. In CI, pair this with a remote cache backend (--cache-to type=registry) so ephemeral runners still get warm caches instead of starting cold every run.
Getting layer ordering right and adopting cache mounts usually cuts build times by more than half on real-world images, and it costs nothing at runtime — it's purely a build-time discipline.