Back to writing

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.

Dockerfile (single-stage)
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:24 base 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.

Dockerfile (multi-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    187MB

Same 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:

Dockerfile (TypeScript, bundled multi-stage)
# ---- 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-stageMulti-stage
Number of FROMOneTwo or more
Final image contentsBuild tools + dev deps + source + appJust the app and its runtime deps
Typical sizeLarge (often 1GB+)Small (tens to low hundreds of MB)
Attack surfaceLarge - full toolchain presentMinimal - only what runs
Build complexitySimplest possibleSlightly more to write
Best forQuick local experimentsAnything 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. -slim variants strip out build tools. alpine goes further. distroless goes 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 in node_modules, .git, and local env files. A .dockerignore keeps your build context - and your build stage - clean.
  • Name your stages. AS build, AS runtime. It's clearer than --from=0 and 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.