Introduction
A typical Node.js Docker image weighs 1.2 GB. A Go binary image can be 800 MB. Most of that weight comes from build tooling, dev dependencies, and operating system packages that have no business being in a production container.
Docker multi-stage builds solve this by separating the build environment from the runtime environment. You compile your application in a fat container with all the tools, then copy only the final artifact into a minimal runtime image.
The result: images that are 10x to 50x smaller, faster to deploy, and more secure.
How Multi-Stage Builds Work
A multi-stage Dockerfile has multiple FROM statements. Each FROM begins a new stage. You can copy artifacts from earlier stages into later ones, leaving behind everything you do not need.
Basic Multi-Stage Syntax
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server
# Stage 2: Runtime
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
The first stage (builder) pulls a 350 MB Go image, downloads modules, and compiles. The second stage starts from a 5 MB Alpine image and copies only the compiled binary. The final image is under 15 MB.
Before and After
Without multi-stage, a typical Go Dockerfile:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
EXPOSE 8080
ENTRYPOINT ["./server"]
Image size: approximately 800 MB. And that binary was compiled with debug symbols, CGO enabled, and no stripping.
With multi-stage:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Image size: approximately 12 MB. The scratch image contains absolutely nothing but the binary.
Real-World Examples by Language
Go: From 800 MB to 12 MB
# Build stage
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w -extldflags=-static" \
-o /app/server ./cmd/server
# Runtime stage
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /usr/local/bin/server
EXPOSE 8080
USER 1000:1000
ENTRYPOINT ["server"]
Key optimizations:
- CGO_ENABLED=0 produces a static binary with no external dependencies
- -ldflags="-s -w" strips debug symbols, reducing the binary by 30 percent
- alpine:3.19 is only 5 MB
- Running as non-root user (USER 1000) improves security
Node.js: From 1.2 GB to 180 MB
# 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 runner
WORKDIR /app
ENV NODE_ENV=production
# Copy only production dependencies and built output
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
Key optimizations:
- npm ci --only=production installs only production dependencies, skipping devDependencies like TypeScript, ESLint, and testing tools
- The devDependencies can reduce image size by 70 percent
- Only dist (compiled output) and production node_modules are copied
- Using node:20-alpine instead of node:20 saves about 600 MB
Python: From 900 MB to 150 MB
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Runtime stage
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 8000
USER 1000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
Key optimizations:
- python:3.12-slim instead of python:3.12 drops the image from 900 MB to 120 MB
- --user flag installs packages to the user site-packages directory, which we copy to the final image
- --no-cache-dir prevents pip from caching downloaded packages in the image
Advanced Multi-Stage Patterns
Using Build Arguments for Environment-Specific Builds
ARG GO_VERSION=1.22
FROM golang:${GO_VERSION}-alpine AS builder
ARG APP_VERSION=latest
WORKDIR /app
COPY . .
RUN go build -ldflags="-X main.version=${APP_VERSION}" -o /server
FROM alpine:3.19
COPY --from=builder /server /server
CMD ["/server"]
Build with different versions:
docker build --build-arg GO_VERSION=1.21 --build-arg APP_VERSION=v2.0 -t myapp .
Caching Dependencies Separately
Layer caching makes rebuilds faster. Copy dependency files first and install before copying source code:
FROM node:20-alpine AS builder
WORKDIR /app
# Cache layer: dependencies rarely change
COPY package.json package-lock.json ./
RUN npm ci
# This layer changes with every source edit
COPY . .
RUN npm run build
FROM node:20-alpine
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Docker caches each layer. When you change source code, only the COPY . and RUN npm run build layers re-execute. The npm ci layer is cached from the previous build, saving 30-60 seconds per rebuild.
Multi-Architecture Builds with BuildKit
Build for both amd64 and arm64 in a single command:
docker buildx build --platform linux/amd64,linux/arm64 \
-t myapp:latest --push .
The multi-stage pattern works identically for both architectures because the compiler produces architecture-specific binaries.
Image Size Optimization Techniques
Using Distroless Images
Google's distroless images contain only your application and its runtime dependencies, no shell, no package manager, no utilities:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /server .
FROM gcr.io/distroless/static-debian12:latest
COPY --from=builder /server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Distroless images are approximately 2 MB plus your binary. They reduce the attack surface significantly since there is no shell, no apt, and no utilities that an attacker could use.
Image Size Comparison
| Approach | Go Image | Node.js Image | Python Image | |----------|---------|---------------|--------------| | Full base image | 800 MB | 1.2 GB | 900 MB | | Alpine base | 280 MB | 350 MB | 300 MB | | Multi-stage + Alpine | 15 MB | 180 MB | 150 MB | | Multi-stage + Distroless | 12 MB | 150 MB | 120 MB | | Multi-stage + Scratch | 10 MB | Not applicable | Not applicable |
The table makes the case clearly: multi-stage builds are not optional if you care about deployment speed and security.
Production Best Practices
Use .dockerignore
Prevent build context bloat by ignoring unnecessary files:
.git
node_modules
dist
.env
*.md
Dockerfile
docker-compose.yml
.gitignore
Without .dockerignore, Docker sends your entire project directory (including node_modules, which can be hundreds of MB) to the Docker daemon on every build.
Scan for Vulnerabilities
After building your slim image, scan it:
# Using Docker Scout (built into Docker Desktop)
docker scout quickview myapp:latest
# Using Trivy (open source)
trivy image myapp:latest
# Using Snyk
snyk container test myapp:latest
Smaller images have fewer packages, which means fewer CVEs. A 12 MB Go binary has approximately zero vulnerabilities versus a 800 MB Go image that may have dozens.
Tag with Git SHA, Not Latest
docker build -t myapp:$(git rev-parse --short HEAD) .
docker push myapp:$(git rev-parse --short HEAD)
Integrate into CI/CD
# .github/workflows/docker-build.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: registry.example.com/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The GitHub Actions cache action shares build cache across CI runs, making repeated builds 50-80 percent faster.
Debugging Multi-Stage Builds
When your multi-stage build fails, you can debug by targeting a specific stage:
# Build only the builder stage
docker build --target builder -t myapp-builder .
# Run the builder stage to inspect
docker run -it --entrypoint sh myapp-builder
# Check what is inside the final image
docker run --entrypoint '' myapp ls -la /app
Common Issues
| Problem | Cause | Fix | |---------|-------|-----| | Binary not found in runtime | Wrong COPY --from path | Verify binary path with --target builder | | Dynamic linking errors | CGO binary needs libc | Use CGO_ENABLED=0 or musl | | Permission denied | Binary owned by root in scratch | COPY with --chown=nonroot | | Node modules missing | Wrong production install | Use npm ci --only=production | | Timezone not set | Alpine has no timezone data | Install tzdata package in runtime stage |
Further Reading
- Docker Compose vs Kubernetes: When to Use What — Once you have slim, production-ready images, the next decision is how to orchestrate them. Compare the two dominant approaches.
- Helm Chart Best Practices: Structure, Values, and Testing — Deploy your multi-stage images with well-structured Helm charts. Covers templating, values management, and chart testing.
- GitHub Actions CI/CD: Complete Guide for Production — Integrate your multi-stage Docker builds into a CI/CD pipeline. The GitHub Actions caching shown in this article pairs with this guide.
- Error Budgets: Stop Wasting Your SRE Team's Time — Smaller, safer images reduce deployment risk. Learn how error budgets give your team a data-driven framework for deciding when to ship.
- 17 Kubernetes Mistakes That Cost Companies Millions — Several of these costly mistakes involve bloated images and poor container practices. Multi-stage builds directly prevent them.
Conclusion
Multi-stage builds are one of the highest-impact optimizations you can make to your container workflow. They reduce image sizes by 10x to 50x, speed up deployments, reduce storage costs, and improve security by minimizing the attack surface.
The pattern is always the same: build in a fat stage, copy only the binary or compiled output to a minimal runtime stage.
Start today: convert your most-used Dockerfile to multi-stage. Pick one language from the examples above (Go, Node.js, or Python) and apply the pattern. Then scan the old and new images with a vulnerability scanner to see the security improvements firsthand. The 10 minutes you invest will pay back every time you deploy.