Docker Multi-Stage Builds: Optimizing Image Size
When building Docker images, one of the most common challenges developers face is keeping image sizes manageable. Large images lead to slower deployments, increased storage costs, and longer pull times from registries. Enter multi-stage builds—a powerful Docker feature that can dramatically reduce your image sizes while keeping your build process clean and maintainable.
The Problem with Traditional Docker Builds
Let’s start by understanding the problem. Consider a typical Node.js application build process:
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
This Dockerfile works, but it has several issues:
- Bloated images: The final image includes all development dependencies, build tools, and source files
- Security concerns: Unnecessary packages increase the attack surface
- Slow deployments: Larger images take longer to push and pull
- Wasted resources: You’re shipping tools that are only needed during the build phase
A typical Node.js image built this way can easily exceed 1GB in size!
What Are Multi-Stage Builds?
Multi-stage builds allow you to use multiple FROM statements in your Dockerfile. Each FROM instruction starts a new build stage, and you can selectively copy artifacts from one stage to another, leaving behind everything you don’t need in the final image.
Think of it as having separate workspaces for building and running your application.
Basic Multi-Stage Build Example
Let’s refactor our Node.js example using multi-stage builds:
# Stage 1: Build stage
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Production stage
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/dist ./dist
RUN npm install --production
EXPOSE 3000
CMD ["node", "dist/index.js"]
Key improvements:
- The builder stage includes all development dependencies
- The production stage only copies the built artifacts and production dependencies
- Using
node:18-alpine(a minimal Linux distribution) further reduces size - Result: Image size reduced from ~1GB to ~150MB!
Real-World Example: Go Application
Go applications benefit even more from multi-stage builds because the compiled binary has no runtime dependencies:
# Stage 1: Build
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 2: Run
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]
Result: From ~800MB (with the full Go toolchain) to just ~15MB!
Advanced Multi-Stage Patterns
1. Separate Dependency and Build Stages
For better caching, separate dependency installation from the build:
# Stage 1: Dependencies
FROM node:18 AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm install
# Stage 2: Build
FROM node:18 AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Production
FROM node:18-alpine
WORKDIR /app
COPY --from=dependencies /app/package*.json ./
RUN npm install --production
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
2. Using Distroless Images
Google’s distroless images contain only your application and runtime dependencies:
# Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app .
# Production stage
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"]
Benefits:
- Minimal attack surface (no shell, package manager, or unnecessary binaries)
- Extremely small size (~2MB base)
- Production-ready security
3. Multi-Platform Builds
Build for multiple architectures in one go:
FROM --platform=$BUILDPLATFORM golang:1.21 AS builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app .
FROM alpine:latest
COPY --from=builder /app/app /app
CMD ["/app"]
Python Application Example
Python applications can also benefit significantly:
# Stage 1: Build
FROM python:3.11 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
Best Practices for Multi-Stage Builds
1. Order Matters for Caching
Place less frequently changing instructions first:
# Good: Dependencies cached separately
COPY package*.json ./
RUN npm install
COPY . .
# Bad: Any source change invalidates npm install cache
COPY . .
RUN npm install
2. Use Specific Tags
Avoid the latest tag in production:
# Good
FROM node:18.17.0-alpine
# Bad
FROM node:latest
3. Minimize Layers
Combine related commands:
# Good
RUN apt-get update && apt-get install -y \
package1 \
package2 \
&& rm -rf /var/lib/apt/lists/*
# Bad
RUN apt-get update
RUN apt-get install -y package1
RUN apt-get install -y package2
4. Use .dockerignore
Prevent unnecessary files from being copied:
node_modules
.git
.env
*.md
.dockerignore
Dockerfile
5. Name Your Stages
Use descriptive stage names for clarity:
FROM node:18 AS development
# ...
FROM node:18-alpine AS production
# ...
Measuring the Impact
Let’s compare image sizes for a typical Node.js application:
| Build Method | Image Size | Reduction |
|---|---|---|
| Single-stage (node:18) | 1.1 GB | - |
| Multi-stage (node:18-alpine) | 180 MB | 84% |
| Multi-stage + production deps only | 120 MB | 89% |
| Multi-stage + distroless | 85 MB | 92% |
Common Pitfalls to Avoid
1. Copying Unnecessary Files
# Bad: Copies everything
COPY . .
# Good: Copy only what's needed
COPY src ./src
COPY package*.json ./
2. Not Using Alpine or Slim Variants
# Wasteful: ~1GB
FROM node:18
# Better: ~180MB
FROM node:18-alpine
3. Installing Build Tools in Production Stage
# Bad: gcc, make not needed at runtime
FROM alpine
RUN apk add gcc make
COPY app .
# Good: Only runtime dependencies
FROM alpine
COPY --from=builder /app/binary .
Debugging Multi-Stage Builds
Build a specific stage for debugging:
# Build only the builder stage
docker build --target builder -t myapp:debug .
# Run it interactively
docker run -it myapp:debug sh
Performance Optimization Tips
1. Use BuildKit
Enable Docker BuildKit for better performance:
DOCKER_BUILDKIT=1 docker build -t myapp .
2. Leverage Build Cache
Use --cache-from to reuse layers from previous builds:
docker build --cache-from myapp:latest -t myapp:new .
3. Parallel Builds
BuildKit supports parallel stage execution:
FROM base AS stage1
RUN slow-operation-1
FROM base AS stage2
RUN slow-operation-2
FROM base AS final
COPY --from=stage1 /output1 .
COPY --from=stage2 /output2 .
Real-World Use Case: Full-Stack Application
Here’s a complete example for a React + Node.js application:
# Stage 1: Build frontend
FROM node:18 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Stage 2: Build backend
FROM node:18 AS backend-builder
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm install
COPY backend/ ./
RUN npm run build
# Stage 3: Production
FROM node:18-alpine
WORKDIR /app
# Copy backend
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=backend-builder /app/backend/package*.json ./
RUN npm install --production
# Copy frontend build
COPY --from=frontend-builder /app/frontend/build ./public
EXPOSE 3000
CMD ["node", "dist/server.js"]
Conclusion
Multi-stage builds are a game-changer for Docker image optimization. By separating build-time and runtime concerns, you can:
- Reduce image sizes by 80-95%
- Improve security by minimizing the attack surface
- Speed up deployments with faster image pulls
- Lower costs through reduced storage and bandwidth usage
- Maintain clean, maintainable Docker files
Start implementing multi-stage builds in your projects today, and watch your Docker images shrink while your deployment speeds soar!
Quick Reference
# Template for multi-stage builds
FROM builder-image AS builder
# Build steps here
WORKDIR /app
COPY . .
RUN build-command
FROM runtime-image
WORKDIR /app
COPY --from=builder /app/output ./
CMD ["run-command"]
Remember: The best Docker image is the smallest one that still runs your application reliably!
Related Articles
- Docker Tutorial for Beginners: Getting Started
- docker tutorial for beginners
- DevOps Tutorial for Beginners
Further Reading: