Docker Multi-Stage Builds: From 1.2GB to 50MB Images
Our Docker images were bloated. 1.2GB for a simple Node.js app. Deployments took 10 minutes.
Multi-stage builds changed everything. 1.2GB → 50MB. Here’s how.
Table of Contents
The Problem
Before:
- Image size: 1.2GB
- Build time: 5min
- Deploy time: 10min
- Registry costs: $200/month
Single-stage Dockerfile:
FROM node:14
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy source
COPY . .
# Build
RUN npm run build
# Start
CMD ["npm", "start"]
Issues:
- Includes dev dependencies
- Build tools in final image
- Source code included
- node_modules bloat
Multi-Stage Build Solution
# Stage 1: Build
FROM node:14 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:14-alpine
WORKDIR /app
# Copy only production files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Results:
- Image size: 1.2GB → 150MB (-87%)
- Build time: 5min → 3min (-40%)
- Deploy time: 10min → 2min (-80%)
Advanced: Distroless Images
# Stage 1: Build
FROM node:14 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Distroless
FROM gcr.io/distroless/nodejs:14
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/index.js"]
Results:
- Image size: 150MB → 50MB (-67%)
- Attack surface: Minimal
- No shell, no package manager
- Security: Excellent
Go Application Example
# Stage 1: Build
FROM golang:1.15 AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 2: Scratch
FROM scratch
COPY --from=builder /app/main /main
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
CMD ["/main"]
Results:
- Image size: 800MB → 8MB (-99%)
- Single binary
- No OS overhead
Python Application
# Stage 1: Build
FROM python:3.8 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY . .
# Stage 2: Runtime
FROM python:3.8-slim
WORKDIR /app
# Copy Python packages
COPY --from=builder /root/.local /root/.local
COPY . .
# Update PATH
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
Results:
- Image size: 1GB → 200MB (-80%)
- No build tools
- Minimal dependencies
Build Optimization
# Use BuildKit for better caching
# syntax=docker/dockerfile:1
FROM node:14 AS builder
WORKDIR /app
# Cache dependencies separately
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
# Cache build
COPY . .
RUN --mount=type=cache,target=/app/.cache \
npm run build
FROM node:14-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
Build with BuildKit:
# Enable BuildKit
export DOCKER_BUILDKIT=1
# Build
docker build -t myapp:latest .
# Results
# First build: 3min
# Subsequent builds: 30s (with cache)
Security Scanning
#!/bin/bash
# scan.sh
# Build image
docker build -t myapp:latest .
# Scan with Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image myapp:latest
# Scan with Snyk
snyk container test myapp:latest
Results:
| Image Type | Vulnerabilities |
|---|---|
| node:14 | 247 |
| node:14-alpine | 12 |
| distroless | 0 |
CI/CD Integration
# .github/workflows/docker.yml
name: Docker Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Results
Image Sizes:
| Application | Before | After | Reduction |
|---|---|---|---|
| Node.js | 1.2GB | 50MB | 96% |
| Go | 800MB | 8MB | 99% |
| Python | 1GB | 200MB | 80% |
Performance:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Build time | 5min | 3min | 40% |
| Deploy time | 10min | 2min | 80% |
| Pull time | 3min | 10s | 95% |
Cost Savings:
- Registry storage: $200/month → $20/month (-90%)
- Bandwidth: $100/month → $10/month (-90%)
- Total: $300/month → $30/month
Lessons Learned
- Multi-stage is essential: 96% size reduction
- Alpine/distroless best: Minimal attack surface
- BuildKit caching: 5x faster builds
- Security matters: Fewer vulnerabilities
- Cost savings significant: 90% reduction
Conclusion
Multi-stage builds transformed our Docker workflow. 1.2GB → 50MB, 96% reduction.
Key takeaways:
- Image size: 1.2GB → 50MB (-96%)
- Deploy time: 10min → 2min (-80%)
- Costs: $300 → $30/month (-90%)
- Security: 247 → 0 vulnerabilities
- Build time: 5min → 3min (-40%)
Use multi-stage builds. Your deployments will thank you.