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 TypeVulnerabilities
node:14247
node:14-alpine12
distroless0

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:

ApplicationBeforeAfterReduction
Node.js1.2GB50MB96%
Go800MB8MB99%
Python1GB200MB80%

Performance:

MetricBeforeAfterImprovement
Build time5min3min40%
Deploy time10min2min80%
Pull time3min10s95%

Cost Savings:

  • Registry storage: $200/month → $20/month (-90%)
  • Bandwidth: $100/month → $10/month (-90%)
  • Total: $300/month → $30/month

Lessons Learned

  1. Multi-stage is essential: 96% size reduction
  2. Alpine/distroless best: Minimal attack surface
  3. BuildKit caching: 5x faster builds
  4. Security matters: Fewer vulnerabilities
  5. Cost savings significant: 90% reduction

Conclusion

Multi-stage builds transformed our Docker workflow. 1.2GB → 50MB, 96% reduction.

Key takeaways:

  1. Image size: 1.2GB → 50MB (-96%)
  2. Deploy time: 10min → 2min (-80%)
  3. Costs: $300 → $30/month (-90%)
  4. Security: 247 → 0 vulnerabilities
  5. Build time: 5min → 3min (-40%)

Use multi-stage builds. Your deployments will thank you.