🐳 Docker Multi-Stage Builds: Stop Shipping Your Compiler to Production
Let me paint you a picture. You've just built a beautiful Go API. The binary is 12MB. Clean. Efficient. Fast. Then you Dockerize it and your image is 1.2GB. You've somehow shipped the entire Go toolchain, a pile of build cache, and spiritually — your dignity — all the way to production.
Sound familiar? Welcome to the club. The good news: multi-stage builds are here to save you from yourself.
The "Before" Disaster 🔥
Here's what most people start with — the single-stage Dockerfile of shame:
# The naive approach (please don't do this)
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o server .
EXPOSE 8080
CMD ["./server"]
This image inherits the entire golang:1.22 base — which is roughly 800MB of compiler, standard library, and tools you will never, ever need at runtime. Your tiny API is buried under a mountain of stuff it doesn't need.
It's like driving to the grocery store in a semi-truck because you needed eggs.
Enter Multi-Stage Builds 🎭
Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Each stage is isolated. You build in one stage, then copy only what you need into a lean final image.
Here's the same Go API, done right:
# Stage 1: The builder — does the heavy lifting
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Stage 2: The runner — ships nothing it doesn't need
FROM gcr.io/distroless/static-debian12
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
The result? Your image goes from 800MB+ down to around 12-15MB. That's not a typo. You've reduced image size by 98% by simply telling Docker "hey, leave the build tools at home."
The magic is COPY --from=builder — it reaches back into the builder stage and grabs just the compiled binary, leaving every other artifact behind like luggage you forgot to pack (intentionally, this time).
Why This Actually Matters (Beyond Bragging Rights)
Faster deployments. Smaller images pull faster in CI/CD pipelines and Kubernetes clusters. When you're scaling up 10 pods during a traffic spike, pulling 12MB vs 800MB is the difference between "handled it gracefully" and "site's down, everyone panic."
Better security. Every tool in your container is a potential attack surface. A compiler, a shell, curl — all of these can be weaponized if an attacker gets a foothold. Distroless images don't even ship a shell. You can't exec into them. That's a feature, not a bug.
Cheaper storage & egress. If you're on AWS ECR or GCP Artifact Registry, you're paying per GB stored and transferred. Multiply that across dozens of services and hundreds of deployments per day. Multi-stage builds pay for themselves fast.
Cache efficiency. By copying go.mod and go.sum before the rest of your source code, Docker can cache the go mod download step. Your dependencies rarely change. Your source code changes constantly. Layer your Dockerfile accordingly and watch your CI build times drop.
The Real-World Lesson I Learned the Hard Way
We had a Node.js service in production. Single-stage build. node_modules included devDependencies. The image was 1.4GB.
One day a dependency had a critical CVE — not in our runtime code, but in a dev tool bundled in the image. The security scanner flagged it. We had to emergency-patch and redeploy because we were technically shipping a vulnerable binary even though it was never executed.
Multi-stage builds would have meant devDependencies never made it into the final image. Here's the Node.js version of that lesson:
# Stage 1: Install everything and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # installs dev deps too
COPY . .
RUN npm run build # compiles TypeScript, etc.
# Stage 2: Production-only
FROM node:20-alpine AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # only production deps
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
Your test runners, TypeScript compiler, linters — all stay in stage 1. Production gets a clean, minimal runtime. Security teams are happy. Finance is happy. You sleep better.
Quick Wins Checklist ✅
Before you ship your next image, run through this:
- Use a minimal base image in your final stage (
alpine,distroless,scratchfor Go) - Copy only build artifacts — not your entire working directory
- Order layers by change frequency — dependencies before source code
- Use
--from=<stage>selectively — you can pull from any named stage - Pin your base image versions —
node:20-alpinenotnode:latest(that's a whole other blog post)
Your Turn 🚀
Pick one service in your stack right now. Run docker image ls and look at the size column. If something's above 500MB and it's not a data science workload, you've got a multi-stage refactor waiting to happen.
The investment is one afternoon. The payoff is faster pipelines, smaller attack surfaces, and the quiet satisfaction of a container that only carries exactly what it needs.
Your production environment isn't a development machine. Stop treating it like one.
Now go slim those images. Your Kubernetes nodes will thank you. 🐋
Have a multi-stage build tip or a horror story about bloated images? Drop it in the comments or find me on X — I read everything.