Docker Multi-Stage Builds: Production-Ready Images

April 5, 2025 · simon balfe

Docker Multi-Stage Builds: Production-Ready Images

How to build optimized Docker images that are secure, fast, and small using multi-stage builds and best practices.

The Problem with Naive Dockerfiles

When I first started containerizing applications, my Dockerfiles were simple but bloated:

FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o main .
CMD ["./main"]

This works, but the resulting image is 800MB because it includes the entire Go SDK, build tools, and source code. For a binary that’s only 15MB.

Enter Multi-Stage Builds

Multi-stage builds solve this by separating build and runtime environments:

# Build stage
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 .

# Runtime stage
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
CMD ["./main"]

Result: Image size drops from 800MB to 15MB.

That’s a 53x reduction. Faster deployments, lower bandwidth costs, smaller attack surface.

Real-World Example: Node.js API

Here’s how I structure a production Node.js Dockerfile:

# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Runtime
FROM node:20-alpine
WORKDIR /app

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# Copy only necessary files
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json ./

USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]

Key Optimizations Explained

1. Use Specific Base Images

# ❌ Bad: Latest tag is unpredictable
FROM node:latest

# ✅ Good: Pinned version + alpine for size
FROM node:20.11-alpine3.19

2. Leverage Layer Caching

Order matters. Put things that change less frequently first:

# ✅ Good: Dependencies cached unless package.json changes
COPY package*.json ./
RUN npm ci
COPY . .

# ❌ Bad: Copies everything first, breaks cache on any file change
COPY . .
RUN npm ci

3. Run as Non-Root User

Never run containers as root:

# Create user
RUN addgroup -g 1001 -S appuser && \
    adduser -S appuser -u 1001

# Switch to non-root user
USER appuser

4. Only Copy What You Need

# Create .dockerignore file
node_modules
.git
.env
*.md
.DS_Store
coverage/
.vscode/

Security Best Practices

Scan for Vulnerabilities

# Use Docker Scout or Trivy
docker scout cves my-image:latest
trivy image my-image:latest

Use Distroless Images

For Go applications, distroless images are even smaller and more secure:

# Runtime stage with distroless
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/main /
CMD ["/main"]

Size: 2MB base image vs 7MB Alpine

Security: No shell, no package manager, minimal attack surface

Multi-Architecture Builds

Build for both AMD64 and ARM64:

# Enable buildx
docker buildx create --use

# Build and push multi-arch image
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myregistry/myapp:latest \
  --push .

This works on both x86 servers and ARM-based instances (AWS Graviton).

CI/CD Integration

Here’s our GitHub Actions workflow for building and pushing images:

name: Build and Push Docker Image

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to ECR
        uses: aws-actions/amazon-ecr-login@v2
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.ECR_REGISTRY }}/myapp:${{ github.sha }}
            ${{ secrets.ECR_REGISTRY }}/myapp:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Key features:

  • Builds on every push to main
  • Tags with git SHA for traceability
  • Uses GitHub Actions cache for faster builds

Health Checks in Dockerfile

Add health checks so Docker knows if your container is healthy:

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1

Docker Compose for Local Development

Keep dev environment consistent:

version: '3.9'

services:
  app:
    build:
      context: .
      target: builder  # Use build stage for hot reload
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
    depends_on:
      postgres:
        condition: service_healthy
  
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

Common Mistakes to Avoid

1. Installing Unnecessary Packages

# ❌ Bad: Installing dev dependencies in production
RUN npm install

# ✅ Good: Only production dependencies
RUN npm ci --only=production

2. Running apt-get update Without Clean

# ❌ Bad: Leaves cache in layer
RUN apt-get update && apt-get install -y curl

# ✅ Good: Cleans cache in same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

3. Not Using .dockerignore

Without .dockerignore, COPY copies everything including node_modules, .git, etc. This breaks caching and bloats context.

Monitoring Image Size Over Time

Track image sizes in CI:

#!/bin/bash
# check-image-size.sh

IMAGE=$1
SIZE=$(docker images $IMAGE --format "{{.Size}}")
echo "Image size: $SIZE"

# Alert if size exceeds threshold
if [[ "$SIZE" == *GB* ]]; then
  echo "❌ Image too large!"
  exit 1
fi

Real-World Impact

After implementing these practices across our services:

  • Average image size: 800MB → 50MB (16x reduction)
  • Build time: 8 minutes → 2 minutes (4x faster)
  • Deployment time: 90 seconds → 15 seconds (6x faster)
  • ECR storage costs: $50/month → $8/month (84% reduction)

Conclusion

Docker images should be:

  • Small - Faster deployments, lower costs
  • Secure - Minimal attack surface, non-root user
  • Efficient - Leverage layer caching
  • Reproducible - Pin versions, use multi-stage builds

Multi-stage builds are the foundation. Start there, then add security hardening, multi-arch support, and monitoring. Your deployment pipeline will thank you.