Docker Multi-Stage Builds: Reducing Image Size by 80%
Our Docker images were huge - 1.2GB for a simple Go service, 800MB for a Python Flask app. This made deployments slow and consumed tons of disk space.
Docker 17.05 introduced multi-stage builds. I spent a week refactoring our Dockerfiles, and the results are impressive: 80% size reduction on average.
Table of Contents
The Problem: Bloated Images
Our original Go service Dockerfile:
FROM golang:1.8
WORKDIR /go/src/app
COPY . .
RUN go get -d -v ./...
RUN go install -v ./...
EXPOSE 8080
CMD ["app"]
Image size: 1.2GB
Why so big? The golang:1.8 image includes:
- Full Debian base (~150MB)
- Go compiler and tools (~400MB)
- Build dependencies (~200MB)
- Our compiled binary (~10MB)
We’re shipping the compiler and build tools to production. That’s wasteful and insecure.
Solution: Multi-Stage Builds
Multi-stage builds let you use multiple FROM statements. Each stage can copy artifacts from previous stages.
New Dockerfile:
# Build stage
FROM golang:1.8 AS builder
WORKDIR /go/src/app
COPY . .
RUN go get -d -v ./...
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
# Production stage
FROM alpine:3.6
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/app/app .
EXPOSE 8080
CMD ["./app"]
Image size: 15MB (down from 1.2GB!)
The magic: COPY --from=builder copies only the compiled binary from the build stage. The final image is based on Alpine (5MB), not the full Go image.
Python Flask Example
Before:
FROM python:3.6
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]
Image size: 800MB
After:
# Build stage
FROM python:3.6 AS builder
WORKDIR /app
COPY requirements.txt .
# Install dependencies to a specific directory
RUN pip install --user -r requirements.txt
# Production stage
FROM python:3.6-slim
WORKDIR /app
# Copy only the dependencies
COPY --from=builder /root/.local /root/.local
COPY . .
# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
EXPOSE 5000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]
Image size: 250MB (down from 800MB)
The python:3.6-slim image is much smaller than the full python:3.6.
Node.js Example
Before:
FROM node:8
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Image size: 900MB
After:
# Build stage
FROM node:8 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM node:8-alpine
WORKDIR /app
# Copy only production dependencies
COPY package*.json ./
RUN npm install --production
# Copy built assets
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
Image size: 80MB (down from 900MB)
Key improvements:
- Alpine base image
- Only production dependencies
- No dev dependencies or source files
Advanced Pattern: Multiple Build Stages
For complex builds, you can have multiple stages:
# Stage 1: Install dependencies
FROM node:8 AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm install
# Stage 2: Run tests
FROM dependencies AS test
COPY . .
RUN npm test
# Stage 3: Build production assets
FROM dependencies AS builder
COPY . .
RUN npm run build
# Stage 4: Production image
FROM node:8-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
You can build specific stages:
# Build and run tests
docker build --target test -t myapp:test .
# Build production image
docker build -t myapp:prod .
Security Benefits
Smaller images are more secure:
- Fewer vulnerabilities - Less software = fewer CVEs
- Smaller attack surface - No compilers or build tools
- Easier to scan - Security scanners run faster
Example vulnerability scan:
# Before (full image)
docker scan myapp:old
Found 247 vulnerabilities
# After (multi-stage)
docker scan myapp:new
Found 12 vulnerabilities
Build Time Optimization
Multi-stage builds can be slower because you’re building multiple images. Optimize with layer caching:
# Bad: copies everything, then installs
FROM golang:1.8 AS builder
COPY . .
RUN go get -d -v ./...
# Good: installs dependencies first (cached layer)
FROM golang:1.8 AS builder
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app .
Now dependency installation is cached unless go.mod changes.
Real-World Results
After migrating all our services:
| Service | Before | After | Reduction |
|---|---|---|---|
| API Gateway (Go) | 1.2GB | 15MB | 99% |
| User Service (Python) | 800MB | 250MB | 69% |
| Frontend (Node) | 900MB | 80MB | 91% |
| Email Service (Python) | 750MB | 230MB | 69% |
Total savings: 3.65GB → 575MB (84% reduction)
Benefits:
- Faster deployments - Smaller images transfer faster
- Less disk usage - Saves money on storage
- Faster CI/CD - Builds and pushes complete quicker
- Better security - Fewer vulnerabilities
Docker Registry Impact
Our private Docker registry was running out of space. After migration:
- Before: 150GB used
- After: 35GB used (77% reduction)
We’re now keeping more image versions without running out of space.
Kubernetes Deployment Speed
Smaller images mean faster pod startup:
| Service | Before | After |
|---|---|---|
| Image pull time | 45s | 8s |
| Pod ready time | 60s | 15s |
This is huge for autoscaling - new pods come online 4x faster.
Best Practices
1. Use specific base images
# Bad: latest tag
FROM python:latest
# Good: specific version
FROM python:3.6-slim
2. Order layers by change frequency
# Dependencies change rarely - cache them
COPY requirements.txt .
RUN pip install -r requirements.txt
# Code changes often - copy last
COPY . .
3. Clean up in the same layer
# Bad: creates two layers
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Good: single layer
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
4. Use .dockerignore
# .dockerignore
.git
.gitignore
README.md
.env
*.log
node_modules
__pycache__
*.pyc
This prevents copying unnecessary files.
Common Pitfalls
1. Forgetting to copy dependencies
# Bug: dependencies not copied
FROM python:3.6-slim
COPY app.py .
CMD ["python", "app.py"] # Fails: no dependencies!
# Fix: copy dependencies
FROM python:3.6-slim
COPY --from=builder /root/.local /root/.local
COPY app.py .
CMD ["python", "app.py"]
2. Using wrong architecture
# Bug: building on Mac, deploying to Linux
FROM alpine
COPY --from=builder /app/binary .
# Fix: specify target OS
RUN CGO_ENABLED=0 GOOS=linux go build -o binary .
3. Missing runtime dependencies
# Bug: binary needs libc
FROM scratch
COPY --from=builder /app/binary .
CMD ["./binary"] # Fails: no libc!
# Fix: use alpine or install dependencies
FROM alpine
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/binary .
CMD ["./binary"]
Monitoring Image Sizes
I created a script to track image sizes:
#!/bin/bash
# check-image-sizes.sh
echo "Image Sizes:"
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | \
grep -E "myapp|myservice" | \
sort -k3 -h
Output:
REPOSITORY TAG SIZE
myapp/api latest 15MB
myapp/user latest 250MB
myapp/frontend latest 80MB
I run this in CI to alert on size increases:
# .gitlab-ci.yml
check-size:
script:
- docker build -t myapp:$CI_COMMIT_SHA .
- SIZE=$(docker images myapp:$CI_COMMIT_SHA --format "{{.Size}}")
- echo "Image size: $SIZE"
- if [ "$SIZE" > "100MB" ]; then echo "Warning: Image too large"; fi
Future: BuildKit
Docker 18.09 will introduce BuildKit with even better caching and parallelization. I’m excited to try it.
Conclusion
Multi-stage builds are a game-changer. They make images smaller, more secure, and faster to deploy.
Key takeaways:
- Use multi-stage builds for all production images
- Use slim/alpine base images
- Copy only what you need to production
- Order Dockerfile layers by change frequency
- Monitor image sizes in CI
Our deployment pipeline is now faster, our registry has more space, and our security posture is better.
If you’re not using multi-stage builds yet, start today. Your future self will thank you.