Skip to content

Layer Cache & Build Ordering

Every Dockerfile instruction produces a layer. Docker stores each layer in a local cache keyed by the instruction text and all layers beneath it. When you run docker build again, Docker checks each instruction from top to bottom:

  • If nothing upstream has changed, it prints CACHED and reuses the stored layer — instantly.
  • The moment any layer changes, Docker invalidates every layer below it and rebuilds from that point on.

This means instruction order has a direct impact on how often you wait for a full rebuild.

The golden rule: order from stable to volatile

Section titled “The golden rule: order from stable to volatile”

Place instructions that change rarely near the top of the Dockerfile and instructions that change often near the bottom. Dependencies change far less frequently than application code.

Bad ordering — cache busts on every code change

Section titled “Bad ordering — cache busts on every code change”
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY . . # ← copies everything including source
RUN npm ci # ← reinstalls ALL deps on every code change
CMD ["node", "src/index.js"]

Every time you edit a .js file, COPY . . changes, which invalidates the RUN npm ci layer. npm ci runs from scratch on every build — even though package.json did not change.

Good ordering — dependencies cached independently

Section titled “Good ordering — dependencies cached independently”
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # ← only manifests
RUN npm ci # ← cached unless manifests change
COPY src/ ./src/ # ← source copied AFTER install
CMD ["node", "src/index.js"]

Now npm ci is only re-executed when package.json or package-lock.json actually change. A code-only edit skips straight to COPY src/ and finishes in seconds.

Each RUN instruction is a layer. Splitting related commands across multiple RUN lines means more layers and more intermediate filesystems to store.

# Inefficient — three layers for one logical operation
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*
# Efficient — one layer, one cache entry
RUN apt-get update \
&& apt-get install -y curl git \
&& rm -rf /var/lib/apt/lists/*

The combined form also ensures rm -rf /var/lib/apt/lists/* runs in the same layer as the install, actually removing the cache bytes from the image.

Unpinned versions break build reproducibility and can cause unexpected cache busts when a new version is published.

# Unpinned — different installs on different days
RUN apt-get install -y curl
# Pinned — reproducible and explicit
RUN apt-get install -y curl=8.5.0-2

For npm, package-lock.json (used by npm ci) already pins all transitive versions — always commit it.

BuildKit supports --mount=type=cache to share a persistent cache directory across builds without baking it into a layer. This is especially effective for package managers.

# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY src/ ./src/
CMD ["node", "src/index.js"]

The npm cache at /root/.npm is preserved between builds in a separate BuildKit cache, not inside the image layer. Subsequent installs resolve packages from the local cache rather than the network — significantly faster on slow connections.

The snippet below builds a simple image twice. The first build downloads the dependency; the second build hits the cache. Watch the timing difference.

# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app

# GOOD ordering: manifests before source
COPY package.json ./
RUN echo '{}' > package-lock.json && npm install --prefer-offline || true

# Source comes last — cache survives source-only edits
COPY . .
CMD ["node","-e","console.log('Cache-friendly build complete!')"]

# --- First build (cold) ---
# docker build -t myapp:cache .
#
# --- Edit a source file, then rebuild (warm cache) ---
# echo "// change" >> index.js
# docker build -t myapp:cache .
# Notice: "npm install" step shows CACHED on the second run
What happens to all layers below a changed layer in a Docker build?
Why should you COPY package.json before COPY src/ in a Node.js Dockerfile?
What is the benefit of combining multiple RUN commands with && instead of writing separate RUN lines?
What does `--mount=type=cache` do in a BuildKit RUN instruction?