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?
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
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

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?
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?
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?
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?
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?
Go deeper
Pro tips
- Build with
--progress=plainand read which steps printCACHED: 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 - 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.
- Docker docs, Build cache invalidation: https://docs.docker.com/build/cache/invalidation/. "If no cached layer matches the instruction exactly, the cache is invalidated"; for ADD and COPY "the builder calculates a cache checksum from file metadata"; "The modification time of a file (mtime) is not taken into account"; for RUN "just the command string itself is used to find a match"; "Once the cache is invalidated, all subsequent Dockerfile commands generate new images and the cache isn't used."
- Docker docs, Docker build cache: https://docs.docker.com/build/cache/. The cache and how order decides what rebuilds. Supports steps 3, 5–6.
- Docker docs, Optimize cache usage in builds: https://docs.docker.com/build/cache/optimize/. Ordering layers, keeping the context small, cache and bind mounts.
- Docker docs, Build context (
.dockerignorefiles): https://docs.docker.com/build/concepts/context/. "files and directories that match patterns in the files are removed from the build context before it's sent to the builder". - Docker docs, Storage drivers: https://docs.docker.com/engine/storage/drivers/. "Commands that modify the filesystem create a new layer"; "the $HOME/.cache directory is removed, but will still be available in the previous layer and add up to the image's total size"; "The major difference between a container and an image is the top writable layer."
- Docker docs, Understanding the image layers: https://docs.docker.com/get-started/docker-concepts/building-images/understanding-image-layers/. Layers stacked into an image. Supports steps 1–2.
- Docker docs, Multi-stage builds: https://docs.docker.com/build/building/multi-stage/. "Each FROM instruction can use a different base, and each of them begins a new stage of the build. You can selectively copy artifacts from one stage to another, leaving behind everything you don't want in the final image."
- Docker docs, Dockerfile reference (FROM, USER, COPY): https://docs.docker.com/reference/dockerfile/. "Each FROM instruction clears any state created by previous instructions"; USER sets the user "for the remainder of the current stage".
- Next.js docs, output (
output: 'standalone'): https://nextjs.org/docs/app/api-reference/config/next-config-js/output. Output file tracing lets a deployment "only include the necessary files". Supports the step-9 note. - npm docs, npm ci (v11): https://docs.npmjs.com/cli/v11/commands/npm-ci. A lockfile is required, and
--omit=devleaves devDependencies out. Supports the stand-innpm's behaviour, which is modelled, not run.
Checked against the tools and docs as of 2026-10-01.