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:
$ docker run --rm notes-api lsDockerfiledistnode_modulespackage-lock.jsonpackage.jsonsrctsconfig.json
Two stages
# 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 buildnames a stage so other stages can refer to it.COPY --from=build /app/dist ./distcopies files out of another stage instead of from the build context.npm ci --omit=devinstalls 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 noderuns the app as the unprivilegednodeuser that the official image provides.
The result
$ docker build -t notes-api .$ docker run --rm notes-api lsdistnode_modulespackage-lock.jsonpackage.json$ docker imagesIMAGE DISK USAGE CONTENT SIZEnotes-api:naive 425MB 95.5MBnotes-api:multi 349MB 80.4MBnode: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.
In the Dockerfile above, which stage's files can a container started from notes-api see?
Useful tricks
Build only one stage with --target. Handy for debugging or for a test stage in CI:
$ docker build --target build -t notes-api:build-stage .$ docker run --rm notes-api:build-stage lsdistnode_modulespackage-lock.jsonpackage.jsonsrctsconfig.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:
#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.
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
What are multi-stage builds and why use them?