🐳 Docker Multi-Stage Builds: Stop Shipping Your Compiler to Production
Let me paint you a picture. You've just Dockerized your Node.js app. You run docker images and see this:
my-app latest 3.2GB
Three. Point. Two. Gigabytes. For a REST API that mostly says "hello world." Somewhere, a DevOps engineer just shed a single tear.
The culprit? You shipped your entire development environment — compiler, build tools, node_modules, and probably some old love letters — straight into production. Multi-stage builds are here to fix that. Let's talk about how.
What Even Is a Multi-Stage Build?
Think of it like cooking a meal. You use a ton of pots, pans, cutting boards, and knives to prepare it — but when you plate the food, you don't also hand your guests a pile of dirty dishes. Multi-stage Docker builds work the same way: use a fat "builder" image to compile and prepare your app, then copy only the finished artifacts into a slim runtime image.
The result? A container that's actually fit for production — lean, fast, and not carrying 400MB of dev tooling it'll never use.
A Tale of Two Dockerfiles
The Before: The Bloated Disaster
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
This seems fine, right? Wrong. That node:20 base image is ~1GB on its own. Add your node_modules (including all the dev dependencies you needed for the TypeScript compiler) and you're staring down a 2–3GB image. It's also a security surface area roughly the size of a small moon.
The After: The Multi-Stage Glow-Up
# ---- Stage 1: Builder ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --include=dev
COPY . .
RUN npm run build
# ---- Stage 2: Runner ----
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
Same app. But now? You only copy the compiled dist/ folder and production dependencies. Dev tools? Gone. TypeScript compiler? Never heard of her. Source maps for your eyes only? Bye.
Real-world result: that 3.2GB image can drop to under 200MB. That's a 94% diet, no fad dieting required.
The Real Lessons From the Trenches
Lesson 1: Alpine Is Your Friend (Until It Isn't)
node:20-alpine uses musl libc instead of glibc. This means smaller images — but some npm packages with native bindings (looking at you, bcrypt and sharp) will throw cryptic errors at runtime. When that happens, switch to node:20-slim instead. Still much smaller than the default, but compatible with more native modules.
Lesson 2: Layer Caching Is the Secret Weapon
Copy package.json and run npm install before copying your source code. Docker caches layers based on what changed. If your source changes but your dependencies didn't, Docker reuses the install layer and saves you minutes on every build. This isn't unique to multi-stage builds, but it becomes even more important when you have multiple stages.
Lesson 3: Name Your Stages Like a Grown-Up
AS builder, AS runner, AS test — naming your stages means you can target specific stages during CI:
# Run tests without building the full production image
docker build --target test -t my-app:test .
This is a game-changer for CI/CD pipelines. Run tests in stage 2, build production in stage 3, and only push stage 3 to your registry. Clean, fast, efficient.
Bonus: The Go Developer's Dream
Go is where multi-stage builds really shine. A Go binary is completely self-contained, which means your final image can be completely empty (the scratch base image has literally nothing in it):
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .
FROM scratch
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"]
Final image size: ~10MB. No OS. No shell. No attack surface. Just your binary, floating in the void. Beautiful.
Why This Matters for CI/CD
Beyond size, multi-stage builds improve your entire pipeline:
- Faster pushes/pulls — smaller images move faster through your registry
- Better security scanning — fewer packages means fewer CVEs to chase down
- Consistent builds — the build environment is codified, not "whatever's on Jenkins today"
- Parallel stages — Docker BuildKit can run independent stages in parallel (use
--build-arg BUILDKIT_INLINE_CACHE=1andDOCKER_BUILDKIT=1)
Your on-call engineer at 3am will thank you when a rollback takes 30 seconds instead of 5 minutes because the images are tiny.
Get Started Today
Audit your existing Dockerfiles right now. If your production image is larger than 500MB and you're not running a database or ML model, you almost certainly have room to slim down.
- Add
AS builderto your currentFROMline - Add a second
FROMwith a slim or alpine base COPY --from=builderonly what you need- Watch your image sizes drop and your CI pipelines speed up
Multi-stage builds are one of those rare Docker features where the upside is enormous and the learning curve is basically a gentle slope. There's no reason not to use them.
Now go look at your Docker images and ask yourself: does this container spark joy? If the answer is "it's 4GB and takes 8 minutes to pull," you know what to do.
Ship leaner. Deploy faster. Sleep better. 🚀