Docker: Single-Stage vs Multi-Stage Builds
You finish building a Node app, write a Dockerfile, run docker build, and ship a 1.2GB image. The app itself is a few megabytes of JavaScript. So where did the other gigabyte come from?
The answer is everything you needed to build the app but nothing you need to run it: the compiler toolchain, dev dependencies, build caches, source files. A single-stage build keeps all of it. A multi-stage build throws it away.
That's the whole idea. Let's see exactly how the two differ and why the gap matters.
What a single-stage build is
A single-stage Dockerfile has one FROM. Everything happens in that one image - you install dependencies, build, and run, all in the same layer stack.
FROM node:24
WORKDIR /app
# Install ALL dependencies, including devDependencies
COPY package*.json ./
RUN npm install
# Copy source and build
COPY . .
RUN npm run build
# Run it
EXPOSE 3000
CMD ["node", "dist/server.js"]This works. It runs. But look at what ends up in the final image:
- The full
node:24base image (~1GB - it includes gcc, make, git, and a complete build toolchain) - Every
devDependency- TypeScript, your bundler, test frameworks, type definitions - Your entire source tree, copied in with
COPY . . - The npm cache from the install step
None of that is needed to run node dist/server.js. But it's all baked into the image you push to production.
Why that's a problem
A bloated image isn't just an aesthetic complaint. It costs you in concrete ways:
Slower deploys. Every push and pull moves the whole image across the network. A 1.2GB image pulled onto ten nodes is 12GB of transfer every deploy. A 180MB image is 1.8GB. That's the difference between a deploy that takes seconds and one that takes minutes.
Bigger attack surface. Every binary in the image is something a vulnerability scanner can flag and an attacker can potentially use. gcc, git, a full shell - none of it should be sitting in a production container. The less you ship, the less there is to exploit.
Wasted registry storage and cost. Images pile up across tags and versions. Multiply a gigabyte by every build you keep and your registry bill notices.
The frustrating part is that you needed all those tools - but only for a few minutes during the build. Multi-stage lets you use them and then leave them behind.
What a multi-stage build is
A multi-stage Dockerfile has multiple FROM statements. Each FROM starts a new stage. The trick is that a later stage can copy files out of an earlier stage with COPY --from=..., but everything else in that earlier stage is discarded.
So you build in a fat stage that has all the tools, then copy only the finished artifacts into a lean final stage.
# ---- Stage 1: build ----
FROM node:24 AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ---- Stage 2: runtime ----
FROM node:24-slim AS runtime
WORKDIR /app
# Only production deps in the final image
COPY package*.json ./
RUN npm install --omit=dev
# Copy ONLY the built output from the build stage
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]The build stage still has the full toolchain and dev dependencies. But it never gets shipped. The only thing that survives is what COPY --from=build pulls into the runtime stage: the compiled dist/ folder.
The final image is built from node:24-slim (a fraction of the size), with production dependencies only, and none of the build clutter.
$ docker build -t myapp:single -f Dockerfile.single .
$ docker build -t myapp:multi -f Dockerfile.multi .
$ docker images
REPOSITORY TAG SIZE
myapp single 1.21GB
myapp multi 187MBSame application. Same behavior. One-sixth the size.
How the copy actually works
The key line is this one:
COPY --from=build /app/dist ./dist--from=build tells Docker: "don't copy from my build context - copy from the stage named build." You can name stages with AS <name>, or reference them by index (--from=0 for the first stage).
You can copy from more than one stage, and you can have more than two stages. And you can push the idea further: bundle your TypeScript app into a single self-contained file, so the runtime stage doesn't even need node_modules:
# ---- Build ----
FROM node:24 AS build
WORKDIR /src
COPY package*.json ./
RUN npm install
COPY . .
# Bundle app + dependencies into ONE file
RUN npx esbuild src/server.ts --bundle --platform=node --outfile=/out/server.js
# ---- Runtime ----
FROM gcr.io/distroless/nodejs24-debian13 AS runtime
COPY --from=build /out/server.js /app/server.js
CMD ["/app/server.js"]Because esbuild inlines every dependency into server.js, the runtime stage needs no package.json, no npm install, no node_modules - just that one file. And distroless/nodejs24 is exactly what it sounds like: the Node runtime and nothing else. No shell, no npm, no package manager, not even a full Linux distro. The final image is around 130MB (almost all of it the Node runtime itself), down from the 1GB+ node:24 build image it came from.
Side by side
| Single-stage | Multi-stage | |
|---|---|---|
Number of FROM | One | Two or more |
| Final image contents | Build tools + dev deps + source + app | Just the app and its runtime deps |
| Typical size | Large (often 1GB+) | Small (tens to low hundreds of MB) |
| Attack surface | Large - full toolchain present | Minimal - only what runs |
| Build complexity | Simplest possible | Slightly more to write |
| Best for | Quick local experiments | Anything you deploy |
"But isn't the second build slower?"
A reasonable worry: you're now installing dependencies and building in one stage, then installing production deps again in another. Isn't that wasteful?
In wall-clock build time, barely. Docker caches layers, so unchanged steps don't re-run. And the build stage's layers are cached too - they just don't end up in the final image. You pay a little extra build time once; you save transfer and storage on every single pull and deploy after that. The trade lands firmly in multi-stage's favor for anything that ships more than once.
There's also a bonus: because build dependencies live in a separate stage, a change to your application source doesn't invalidate the dependency-install layer. Order your steps well (copy package.json and install before copying source) and most rebuilds skip the slow install entirely.
A few practical tips
- Pick the leanest runtime base you can.
-slimvariants strip out build tools.alpinegoes further.distrolessgoes furthest - just the language runtime, nothing else. Match the base to what your app genuinely needs at runtime. - Use
--omit=dev(or--production) in the runtime stage so dev dependencies never make it in. - Add a
.dockerignore.COPY . .will happily drag innode_modules,.git, and local env files. A.dockerignorekeeps your build context - and your build stage - clean. - Name your stages.
AS build,AS runtime. It's clearer than--from=0and survives reordering. - You can target a stage directly.
docker build --target build .stops at the build stage - handy for running tests in CI against the full toolchain without shipping it.
When single-stage is fine
Single-stage isn't wrong, it's just narrow. If you're throwing together a quick container to test something locally, or your app genuinely needs nothing more than its base image (a plain JavaScript script with no build step on node:24-slim, say), one stage is simpler and perfectly fine.
The mistake is letting that quick-and-dirty Dockerfile become the one you deploy. The moment an image gets pushed to a registry and pulled onto real infrastructure, every megabyte and every stray binary starts to matter.
The takeaway
Single-stage and multi-stage builds produce the same running app. The difference is what they leave behind. Single-stage ships your entire workshop - tools, scraps, and all. Multi-stage ships only the finished product.
For anything you deploy, multi-stage should be the default. It costs a few extra lines in your Dockerfile and pays you back on every deploy with smaller, faster, safer images.
Write the build stage fat. Ship the runtime stage lean.
I write about DevOps, full-stack development, and building in public as a CS student. Follow me on Twitter/X for more posts like this.