sehaj/learn
Docker/02 Images

02.04 · with code · 13 min

Multi-stage builds

Build with everything, ship with nothing extra. One Dockerfile, two images' worth of stages, and only the last one ships.

The core idea

Building software needs tools that running it doesn't: compilers, dev dependencies, test frameworks. If you build in the same image you ship, all of that ships too.

A multi-stage build has several FROM lines in one Dockerfile. Each starts a new stage. Earlier stages do the heavy work, and the final stage copies in only the results. Only the final stage becomes your image.

The problem, measured

The single-stage notes API image contains TypeScript, the type definitions and the source, none of which run in production:

❯terminal
$ docker run --rm notes-api ls
Dockerfile
dist
node_modules
package-lock.json
package.json
src
tsconfig.json

Two stages

Dockerfile
# syntax=docker/dockerfile:1

# Stage 1: compile TypeScript. Has dev dependencies and the compiler.
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# Stage 2: what actually ships. Production dependencies and compiled JS only.
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

What's new:

  • FROM ... AS build names a stage so other stages can refer to it.
  • COPY --from=build /app/dist ./dist copies files out of another stage instead of from the build context.
  • npm ci --omit=dev installs only runtime dependencies in the final stage. The notes API has none yet, so this installs nothing; it starts to matter once Postgres arrives in 04.05 Project: persist Postgres.
  • USER node runs the app as the unprivileged node user that the official image provides.
What ends up where
Stage: buildthrown away
dist/ (compiled)
src/
tsconfig.json
node_modules (typescript, @types)
node:22-slim
Final imageshipped
dist/ (copied)
package.json
lockfile
node:22-slim
The build stage's layers never reach the final image. Only the files you COPY --from cross over.

The result

❯terminal
$ docker build -t notes-api .
$ docker run --rm notes-api ls
dist
node_modules
package-lock.json
package.json
$ docker images
IMAGE DISK USAGE CONTENT SIZE
notes-api:naive 425MB 95.5MB
notes-api:multi 349MB 80.4MB
node:22-slim 349MB 82.6MB

Output trimmed. The multi-stage image is essentially the base image: your app adds a few kilobytes. Everything that was only needed to build is gone. (node_modules here is an empty folder npm creates; there are no runtime dependencies yet.)

The two columns: disk usage is the unpacked size on your machine, content size is the compressed size that gets pushed and pulled. Docker Desktop's newer image store shows both; older setups show a single SIZE.

Predict first

In the Dockerfile above, which stage's files can a container started from notes-api see?

Commit to an answer before you read on.

Useful tricks

Build only one stage with --target. Handy for debugging or for a test stage in CI:

❯terminal
$ docker build --target build -t notes-api:build-stage .
$ docker run --rm notes-api:build-stage ls
dist
node_modules
package-lock.json
package.json
src
tsconfig.json

Stages run in parallel when they can. BuildKit works out which steps depend on which. In the build log for this Dockerfile, the final stage's npm ci --omit=dev starts before the build stage finishes compiling, because it doesn't need anything from it:

❯terminal
#9 [build 3/7] COPY package.json package-lock.json ./
#10 [stage-1 4/5] RUN npm ci --omit=dev
#11 [build 4/7] RUN npm ci
...
#14 [build 7/7] RUN npm run build
#15 [stage-1 5/5] COPY --from=build /app/dist ./dist

Copy from any image, not just your stages: COPY --from=busybox:1.36 /bin/busybox /bin/busybox.

CheckpointConvert the notes API

Rewrite your Dockerfile as two stages, rebuild, and confirm with docker run --rm notes-api ls that src and tsconfig.json are gone. Check that docker exec into a running container and whoami prints node.

STUCK? ▸

COPY --from=build fails with "not found": check the stage is named AS build and the path matches where that stage wrote its output (/app/dist). Permission errors at startup: files copied in are owned by root but readable, which is fine; the app only needs to read them.

Recap

Interview questions

MidfreqMust-knowConcept

What are multi-stage builds and why use them?

Report a mistakeEdit this page

Used in

☰ Images02.04 · 10/44next →