0x55aa
← Back to Blog

🐳 Docker Multi-Stage Builds: Stop Shipping Your Compiler to Production

5 min read

🐳 Docker Multi-Stage Builds: Stop Shipping Your Compiler to Production

Let me paint you a picture. You've just Dockerized your shiny new Go API. You run docker images and see this:

my-awesome-api   latest   a1b2c3d4e5f6   2 minutes ago   1.23GB

1.23 gigabytes. For an API that does three things. Your production server is now hauling around GCC, the entire Go toolchain, and half of npm for absolutely no reason. Congratulations — you're shipping your build environment to production.

This is the Docker equivalent of bringing the entire IKEA store home when you only bought a bookshelf.

The fix? Multi-stage builds. And once you learn this trick, you'll never go back.

What's the Problem, Exactly?

When most developers first write a Dockerfile, it looks something like this:

FROM golang:1.22

WORKDIR /app
COPY . .
RUN go build -o server .

EXPOSE 8080
CMD ["./server"]

This works! It compiles your code and runs it. But here's the dirty secret: that golang:1.22 base image is ~800MB before you've even added a single line of your code. You're including the Go compiler, the standard library sources, build tools, and a whole operating system — none of which your running application actually needs.

The binary your app produces? Maybe 15MB. You're just dragging the rest along for the ride.

Enter Multi-Stage Builds

Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Each stage is isolated, and crucially — you can selectively copy artifacts from one stage to another, leaving all the build-time junk behind.

Here's that same Go API, done right:

# Stage 1: The Builder
FROM golang:1.22-alpine 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
FROM alpine:3.19

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY --from=builder /app/server .

USER appuser
EXPOSE 8080
CMD ["./server"]

Run docker images now:

my-awesome-api   latest   b2c3d4e5f6a7   2 minutes ago   18.4MB

18.4MB. We went from 1.23GB to 18MB. That's a 98.5% reduction without changing a single line of application code. Go home everyone, we're done.

Why This Matters Beyond Just Disk Space

"Okay cool, I save some disk space. Big deal." — you, probably, before I drop this on you:

Security surface area. Every package in your container is a potential vulnerability. That Go compiler you were shipping? It has CVEs. The build tools? More CVEs. The scratch or alpine final image has almost nothing in it, which means almost nothing to exploit.

Pull times in CI/CD. When your Kubernetes cluster needs to pull your image on 50 nodes during a rolling deploy, the difference between 18MB and 1.2GB is the difference between a smooth deployment and your on-call engineer getting paged at 2am.

Registry costs. If you're pushing hundreds of builds per day to ECR, GCR, or Docker Hub — those gigabytes add up fast. Multi-stage builds are basically free money.

The Pattern Works for Every Language

Node.js developers, I see you. You're not off the hook:

# Build stage
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine AS production

ENV NODE_ENV=production
WORKDIR /app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json .

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

This pattern eliminates dev dependencies, source files, test configs, and anything else that doesn't need to make it to prod. Your devDependencies in package.json can be as bloated as you want — they'll never see a production server.

Lessons Learned the Hard Way

A few things I've tripped over that might save you some pain:

Don't use latest tags in base images. I know, I know — I used them above for clarity. But in a real pipeline, pin your versions (node:20.11.0-alpine3.19). "But it worked yesterday!" is not a valid incident postmortem.

Cache your dependency layers. Notice how both Dockerfiles copy go.mod/package.json before copying source code? Docker's layer cache is your best friend. Dependencies change far less often than your source code, so keeping them in separate layers means you're not re-downloading half the internet on every build.

Be careful with COPY . . in the builder stage. Add a .dockerignore file to exclude node_modules/, .git/, test fixtures, and local config files. Otherwise you're copying things into the build context that'll just slow you down or leak secrets.

Test your images locally before pushing. Seriously. Run docker run --rm -it your-image sh and poke around. I once spent two hours debugging a production issue that turned out to be a missing timezone database that the compiler image happened to include but the final scratch image didn't.

The Bottom Line

Multi-stage builds are one of those rare optimizations where the effort is minimal and the payoff is massive. You write one Dockerfile, you get smaller images, better security posture, faster deployments, and lower infrastructure costs.

There's genuinely no reason not to use them on every new project — and there's a very good reason to go retrofit them onto your existing ones this week.

Go check your current image sizes right now. I'll wait.

docker images | sort -k7 -h

Horrifying, right? Now you know what to do.


Have a multi-stage build tip I missed, or a war story about a 4GB Docker image? Drop it in the comments or find me on Twitter/X. And if this saved your production cluster some pain, give it a share — your fellow DevOps engineers will thank you.