Skip to content

Layers & the Build Cache

Every Dockerfile instruction creates a layer. Docker stores these layers on disk and assigns each one a cache key based on:

  • The instruction text itself
  • The parent layer’s cache key
  • (For COPY/ADD) the checksum of the copied files

When you rebuild an image, Docker checks each instruction against the cache. If the cache key matches, Docker reuses the cached layer — instant, zero-cost. If the cache key does not match (the instruction changed, or a copied file changed), Docker invalidates that layer and every layer after it, rebuilding them from scratch.

This is the critical rule:

Cache invalidation cascades downward. Once a layer is invalidated, all subsequent layers are also rebuilt.

Consider this poorly ordered Dockerfile:

# BAD: copies all source code first
FROM node:22-alpine
WORKDIR /app
COPY . . # ← copies everything, including app source
RUN npm ci --omit=dev # ← runs after COPY — cache busted on every code change

Every time you change a single source file, COPY . . is invalidated, which also invalidates RUN npm ci. That means npm reinstalls all dependencies on every build — even if package.json did not change.

The fix is to copy dependency manifests first, install, then copy source:

# GOOD: dependency install is cached separately
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # ← only changes when deps change
RUN npm ci --omit=dev # ← cached as long as package.json unchanged
COPY . . # ← source changes only bust this layer

Now npm ci is only re-run when package.json or package-lock.json changes. Changing app code only invalidates the final COPY . . and anything below it.

First build — everything is a fresh download:

[+] Building 14.7s (7/7) FINISHED
=> [1/5] FROM docker.io/library/node:22-alpine 5.2s
=> [2/5] WORKDIR /app 0.0s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev 8.1s
=> [5/5] COPY . . 0.0s
=> exporting to image 1.4s

Second build — only app source changed, npm ci is cached:

[+] Building 1.1s (7/7) FINISHED
=> [1/5] FROM docker.io/library/node:22-alpine 0.0s
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] COPY package.json package-lock.json ./ 0.0s
=> CACHED [4/5] RUN npm ci --omit=dev 0.0s
=> [5/5] COPY . . 0.0s
=> exporting to image 1.1s

The build went from 14.7 s to 1.1 s because four of five layers were served from cache.

Order instructions from least frequently changing to most frequently changing:

  1. FROM (base image — rarely changes)
  2. System package installs (RUN apt-get install ...)
  3. Dependency manifests copied + installed (COPY package.json ./ + RUN npm ci)
  4. Application source code (COPY . .)
  5. Build steps that depend on source (RUN npm run build)

The snippet below builds a tiny image twice. Run it and watch the second build use CACHED layers.

# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
# Step 1: copy only the manifest and install
RUN echo '{"name":"demo","version":"1.0.0"}' > package.json
RUN npm install --omit=dev 2>/dev/null || true
# Step 2: copy "source" (simulated with an echo)
RUN echo "console.log('Hello from app v1');" > app.js
CMD ["node", "app.js"]
# --- first build ---
# docker build -t cachetest .
# docker run --rm cachetest
# --- change "source" and rebuild: only the last RUN reruns ---
# docker build -t cachetest .
What happens to all layers below an invalidated layer?
Why do we COPY package.json before COPY . . in a Node.js Dockerfile?
Which of these instructions creates a new layer in the image?
A second `docker build` on unchanged source prints `CACHED` for most steps. What does that mean?