Skip to content

Docker images, layers and multi-stage builds

An image is cached layers built in order: the first changed step rebuilds everything after it, and only the final stage ships.

Predict first

Before you read: predict

ExamHall's admin-console API is built from this Dockerfile, and its last build is in the local cache. A developer edits one file, src/main.ts, and builds again on the same machine. No other file changes.

FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/main.js"]

Which lines run again instead of being reused from the cache?

Pick one

The 30-second version

An image is a stack of layers; each RUN, COPY or ADD adds one. A rebuild reuses a step's cached layer while its instruction, and for COPY the copied files' contents, are unchanged; from the first changed step, every later step in that stage rebuilds. A multi-stage image is its last stage, holding only what you copy into it.

An image is cached layers built in order: the first changed step rebuilds everything after it, and only the final stage ships.

How it works

Step 1 of 10

Image, container. An image is a read-only stack of layers; a container adds its own writable layer, and its writes never reach the image.

Analogy: A road crew relaying a pavement's courses, and the asphalt plant

A road is laid in courses, each on the one below. When a stretch is rebuilt, the crew keeps every course below the first one that must change, and lays that course and every course above it again.

On the road In Docker
the courses, laid bottom to top layers, built in Dockerfile order (steps 2–3)
a course kept because it and everything under it are unchanged a CACHED step (step 3)
a changed course, and every course above it relaid the first miss, and every later step run again (step 5)
the courses that rarely change, put lowest the install before the source copy (step 6)
black paint over an old lane line: hidden, still in the surface a file deleted in a later layer (step 8)
the plant's drum, burners and stockpiles stay at the plant; only the mix goes out on the trucks the build stage, and the final stage that receives only copied files (step 9)

In real terms: BuildKit walks the Dockerfile in order, reuses each layer whose instruction and inputs match, rebuilds from the first mismatch to the end of the stage, and ships only the last stage.

Breaks down when: a crew judges by eye which course is worn; BuildKit compares exact checksums, so a reformatted package.json with the same dependencies still counts as changed. A road keeps one surface, while the cache keeps old and new layers side by side. A relaid course is torn up first; a rebuilt layer is written beside the old one.

Contrasting case: a hedge trimmed each spring. Cutting off growth leaves a smaller hedge, because nothing is kept underneath. An image is not like that: deleting files adds a layer (step 8).

Examples, run for real

4.1 ExamHall EXH-2070: the admin-console API image ships its compiler

EXH-2070: the admin-console API image held TypeScript, the Nest CLI and the source, because one stage both built and ran it. MR !2370 splits it into a build stage and a runtime stage:

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/main.js"]
4.1 one stage (before)       adds 10 MB on top of node:22-alpine · node_modules/typescript yes · node_modules/@nestjs/cli yes · src yes · dist yes · starts dist/main.js
4.1 two stages (MR !2370)    adds 2 MB on top of node:22-alpine · node_modules/typescript no · node_modules/@nestjs/cli no · src no · dist yes · starts dist/main.js

The final stage starts from a clean base (step 9). Only dist/ crosses from the build stage, and the runtime install leaves out devDependencies.

4.2 Proof domain, e-commerce checkout: a source edit

The checkout service copies its package files and installs before it copies the source. A developer edits src/cart.ts and builds again:

4.2 after editing src/cart.ts: WORKDIR /app CACHED · COPY package.json package-lock.json ./ CACHED · RUN npm ci CACHED · COPY . . re-run · RUN npm run build re-run

The package files did not change, so their copy and the install are reused (steps 3–4). COPY . . copies the edited file, so it and the build run again (step 5).

Picture it

a charcoal box seen from one corner above a soft shadow, with lime corner braces, a lavender latch bar and pale-cyan panels; nothing is labelled.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

The checkout service's last build, from the Dockerfile below, is in the cache. The release script then changes one line of package.json, "version": "2.3.0" to "version": "2.3.1", and no dependency changes. The image is built again.

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/main.js"]

Which lines run again?

Pick one

Question 2 of 5

A ticketing API's image ends with a prune step. Here npm ci writes 12 MB into node_modules, 9 MB of it devDependencies; npm run build writes a 2 MB dist/; and npm prune --omit=dev deletes those 9 MB. The source comes to a few kilobytes.

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
CMD ["node", "dist/main.js"]

To the nearest MB, how much does the built image add on top of its node:22-alpine base?

Pick one

Question 3 of 5

A chat backend now builds in two stages. The build stage's npm ci writes 16 MB, 12 MB of it devDependencies; the final stage's npm ci --omit=dev writes the other 4 MB. The source and dist/ come to a few kilobytes.

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app ./
CMD ["node", "dist/main.js"]

To the nearest MB, how much does the final image add on top of node:22-alpine?

Pick one

Question 4 of 5

A catalog API builds in two stages. The API process must run as the node user (uid 1000), not as root.

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
USER node

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
____
CMD ["node", "dist/main.js"]

Which line belongs in the slot of the final stage?

Pick one

Question 5 of 5

A video-streaming API's repository root holds src/, package.json, package-lock.json, CHANGELOG.md and docs/. Every merge to main edits CHANGELOG.md and a page under docs/; the image needs neither. After a merge that changed nothing else, the build ran RUN npm run build again.

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/main.js"]

Which one change lets such a merge reuse the cached npm run build layer?

Pick one

Go deeper

Pro tips
  1. Build with --progress=plain and read which steps print CACHED: the first step without it is where your cache broke. When: a build is slow after a small change (steps 3–5). Verify (RAN, tips.mjs):
    tip1 [1] WORKDIR /app -> CACHED
    tip1 [2] COPY package.json package-lock.json ./ -> CACHED
    tip1 [3] RUN npm ci -> CACHED
    tip1 [4] COPY . .
    tip1 [5] RUN npm run build
  2. Read docker history --human=false <image>: each layer's size sits next to the step that wrote it, newest first. When: an image is bigger than you expected (steps 2, 8). Verify:
    tip2 0	CMD ["node" "dist/main.js"]
    tip2 16384	RUN /bin/sh -c npm run build
    tip2 24576	COPY . .
    tip2 10059776	RUN /bin/sh -c npm ci
    tip2 16384	COPY package.json package-lock.json ./
    tip2 8192	WORKDIR /app
Source

All fetched 2026-10-01; vendor docs can change, so each is valid as of 2026-10-01.

Checked against the tools and docs as of 2026-10-01.