The best reason to use multi-stage Docker builds is not aesthetics. It is discipline.
Production images should contain the application and the runtime it needs to execute. They should not also contain:
- compilers
- package manager caches
- test tooling
- source files that are only useful at build time
Multi-stage builds make that separation explicit.
The Pattern
Build in one stage, run in another:
FROM node:22-bookworm AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN pnpm prune --prod
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
USER node
CMD ["node", "dist/server.js"]
That gives you a clean boundary between "what is required to build" and "what is required to run."
The Alpine Caveat
Alpine can be a good runtime image. It is not automatically the right one.
You still need to think about:
- native dependencies
- libc compatibility
- debugging needs
- image provenance
Sometimes a slim Debian-based image is the better operational trade. The principle is to ship less, not to cargo-cult a specific base image.
What This Buys You
Done well, multi-stage builds improve:
- image size
- cold start and pull time
- attack surface
- clarity around runtime dependencies
That is why they are worth doing even when the image size difference is modest.
Further Reading