🎉 DevOps Interview Prep Bundle is live — 1000+ Q&A across 20 topicsGet it →
All Articles

Docker Multi-Stage Builds Explained Simply

Learn what Docker multi-stage builds are and why they matter. Includes real examples shrinking a Go app from 800MB to 15MB and a Node.js app from 600MB to 80MB.

DevOpsBoys4 min read
Share:Tweet

When you first learn Docker, you write a Dockerfile that installs your compiler, copies your code, builds it, and runs it — all in one image. The result is a container image that is hundreds of megabytes and includes build tools that have no business being in production. Multi-stage builds fix this.

The Problem: Build Tools in Production Images

Here is a typical Go Dockerfile without multi-stage builds:

dockerfile
FROM golang:1.22
 
WORKDIR /app
COPY . .
RUN go build -o server .
 
CMD ["./server"]

Build it and check the size:

bash
docker build -t myapp:single .
docker images myapp
REPOSITORY   TAG       IMAGE ID       SIZE
myapp        single    a3b4c1d2e5f6   823MB

823 MB for a binary that is 8 MB. You are shipping the entire Go compiler, standard library sources, and Go module cache into production. This is slow to pull, expensive to store, and increases your attack surface.

How Multi-Stage Builds Work

Multi-stage builds let you use multiple FROM statements in one Dockerfile. Each FROM starts a new stage. You can copy files from one stage to another, and Docker only includes the final stage in the output image.

dockerfile
# Stage 1: Build
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: Run
FROM alpine:3.19
 
WORKDIR /app
COPY --from=builder /app/server .
 
CMD ["./server"]

The --from=builder line copies the compiled binary from the builder stage into the final Alpine image. The compiler, source code, and Go module cache are discarded. They never appear in the output image.

bash
docker build -t myapp:multi .
docker images myapp
REPOSITORY   TAG       IMAGE ID       SIZE
myapp        single    a3b4c1d2e5f6   823MB
myapp        multi     b7e2f3a1c8d9   14.6MB

823 MB → 14.6 MB. Same application. Same binary.

For even smaller images, use FROM scratch instead of Alpine. This gives you a truly empty base image — just the binary. Works well for statically compiled Go binaries:

dockerfile
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/server"]

Node.js Example: 600MB → 80MB

Node.js apps have a different problem: node_modules includes devDependencies that are only needed during build. Multi-stage builds let you install everything to build, then copy only the production modules.

dockerfile
# Stage 1: Install all deps and build
FROM node:20-alpine AS builder
 
WORKDIR /app
COPY package*.json ./
RUN npm ci
 
COPY . .
RUN npm run build
 
# Stage 2: Production only
FROM node:20-alpine AS runner
 
ENV NODE_ENV=production
WORKDIR /app
 
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
 
COPY --from=builder /app/dist ./dist
 
CMD ["node", "dist/index.js"]
myapp   build   ...   598MB
myapp   prod    ...   82MB

The npm ci --omit=dev in the final stage installs only production dependencies. TypeScript, ESLint, Jest, and all your devDependencies stay behind in the builder stage.

What Gets Discarded Between Stages

Docker builds each stage as a separate image layer chain. When you copy --from=stage-name, you copy only the files you explicitly reference. Everything else in that stage — the OS packages, build tools, temp files, source code — is thrown away.

Specifically, these do NOT appear in your final image:

  • The compiler (Go, Rust, Java JDK)
  • Source code files
  • Test frameworks
  • Build caches
  • Dev dependencies
  • node_modules devDependencies
  • Intermediate artifacts

Only what you explicitly COPY --from= carries forward.

Building Only One Stage

You can build up to a specific stage using --target. This is useful in CI when you want to run tests in the build stage before producing the final image:

bash
# Run tests in the builder stage only
docker build --target builder -t myapp:test .
docker run --rm myapp:test go test ./...
 
# If tests pass, build the full image
docker build -t myapp:prod .

This pattern avoids building the final production image when tests fail.

Naming Stages

You can name stages anything and reference them by name:

dockerfile
FROM node:20-alpine AS deps
FROM node:20-alpine AS test
FROM node:20-alpine AS builder
FROM nginx:alpine AS production

Then in CI:

bash
docker build --target test -t myapp:test .
docker build --target production -t myapp:prod .

Common Mistakes

Using the wrong base for the final stage. If your Go binary uses CGO (calls C libraries), it will not run on alpine or scratch without the C runtime. Either disable CGO with CGO_ENABLED=0 at build time, or use debian:slim as your final stage.

Not copying SSL certificates. If your app makes HTTPS calls, the final stage needs the CA certificate bundle. Add COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ when using scratch or a minimal base.

Copying the entire directory instead of specific files. Be precise about what you copy. COPY --from=builder /app . copies everything including source code. Copy only the built artifact.

Summary

Multi-stage builds are the correct way to build container images for production. The pattern is:

  1. Use a full-featured base image (language runtime with compiler) to build your artifact
  2. Copy only the artifact into a minimal final image
  3. Ship that minimal image

You get smaller images, faster pulls, less attack surface, and cleaner separation between build and runtime concerns. Once you start using multi-stage builds, you will not go back to single-stage.

🔧

Today I Fixed

Short real fixes from production — posted daily

Browse fixes
Newsletter

Stay ahead of the curve

Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.

Related Articles

Comments