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:

  1. Fewer vulnerabilities - Less software = fewer CVEs
  2. Smaller attack surface - No compilers or build tools
  3. 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:

ServiceBeforeAfterReduction
API Gateway (Go)1.2GB15MB99%
User Service (Python)800MB250MB69%
Frontend (Node)900MB80MB91%
Email Service (Python)750MB230MB69%

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:

ServiceBeforeAfter
Image pull time45s8s
Pod ready time60s15s

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:

  1. Use multi-stage builds for all production images
  2. Use slim/alpine base images
  3. Copy only what you need to production
  4. Order Dockerfile layers by change frequency
  5. 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.