Multi-Stage Builds: Separating the Build from What Ships
The point of a multi-stage build is not a smaller image, it is a boundary. Everything the compiler needed stays on the far side of the line you draw.
People reach for multi-stage builds to shrink an image, and the size reduction is real. But the reason the pattern is worth internalising is different: it draws a line between two things that were previously the same blob — what you need to build the artifact and what you need to run it.
Almost every container security and hygiene problem I have cleaned up was really a boundary problem. The compiler, the package manager cache, the source tree, and the SSH key used to fetch a dependency all ended up in the thing that ships.
The boundary, stated plainly
A multi-stage build is two (or more) FROM lines, where the final stage copies artifacts forward and leaves everything else behind. Nothing is hidden — the layers you do not copy simply are not in the final image.
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- the line ----
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
CMD ["node", "dist/server.js"]
Read the last stage as a specification of what the runtime is allowed to contain. If you cannot justify an entry, it does not belong on that side of the line.
What deliberately stays behind
- Toolchains — compilers, language SDKs,
make, debuggers. They exist to produce the artifact. - Build caches —
~/.cargo,~/.npm,go buildcache. Big, mutable, and useless at runtime. - Source and VCS metadata —
.gitin an image is a habit worth breaking; it leaks history and bloats layers. - Secrets and credentials — anything used to fetch a dependency. This is the one with consequences: a build-scoped token left in a shipped layer is readable by anyone who can pull the image.
The last point is where the boundary earns its keep. BuildKit secret mounts and --mount=type=cache give the build stage everything it needs without those values ever becoming a layer at all.
Where teams get this wrong
Copying too much forward. Bringing the whole node_modules from the build stage is normal for Node because of native modules, but it drags dev dependencies with it. Either build a production install separately in the final stage, or accept the trade-off knowingly — the silent version is the bad one.
Stacking unrelated jobs into one stage. A lint stage, a test stage, and a compile stage that each FROM a different base give you parallelism and cache independence. Folding them together means one cache miss rebuilds everything.
Ignoring cache locality. Copying package.json before the rest of the source is not a size trick — it lets the dependency install layer stay cached while source changes every commit. That is a build-time decision, and it is the reason the same file appears twice in most Dockerfiles.
What you gain beyond the size
The image gets smaller; nobody argues with that. Two other effects have been worth more to me.
You can answer "what is running?" by reading the file. The final FROM is a short, reviewable allowlist. That is a far easier question to answer than "what is in this 900 MB image?" — and it is the reason a boundary beats a list of things you remembered to exclude. When the answer is a dozen lines in a pull request, reviewers actually check it.
Build credentials have no path into the shipped layers. A token used to fetch a private dependency exists only inside the build stage, so there is nothing to leak when the image is later scanned for provenance and signatures. Before this split, the usual mitigation was an aggressive cleanup step on the way out — a pattern that fails silently the first time some new tool writes to a path nobody thought to delete.
Neither effect shows up in a size badge. Both show up the first time someone asks what is deployed, or the first time an image is handed to a team that has to trust it.
Summary
Treat the final FROM as an allowlist for production: only the artifact, its runtime dependencies, and the base image. Toolchains, caches, source, and build credentials stay in earlier stages where they are needed and then vanish. The image gets smaller as a side effect, but the durable win is that the boundary is explicit, reviewable in a pull request, and impossible to accidentally ship a secret across.
SDP Clouds Team
DevOps and cloud engineers writing practical, battle-tested guides on CI/CD, Kubernetes, infrastructure as code, and production operations — every article is based on real incidents and real pipelines, not docs-page rewrites.
More about us →