Docker Multi-Stage Builds: From 1.2GB to 100MB
TL;DR
Multi-stage builds use multiple FROM statements. Build stage has compilers and tools. Final stage has only runtime dependencies. Result: 10x smaller images, faster deploys, fewer vulnerabilities. Copy artifacts between stages.
Our Docker image was 1.2GB. Deployment took 5 minutes. Then I discovered multi-stage builds. Same app, 100MB image. Deployments dropped to 30 seconds. One Dockerfile change.
Here's how to shrink your images 10x with multi-stage builds.
The Problem: Bloated Images
# Single-stage build (BAD)
FROM node:18
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy source
COPY . .
# Build
RUN npm run build
# Start
CMD ["node", "dist/server.js"]
Problems:
- Includes npm (not needed in production)
- Includes dev dependencies
- Includes source code
- Includes build tools
- Result: 1.2GB image
Multi-Stage Build: Separate Build and Runtime
# Build stage
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Runtime stage
FROM node:18-alpine
WORKDIR /app
# Copy only production dependencies
COPY --from=builder /app/node_modules ./node_modules
# Copy only built files
COPY --from=builder /app/dist ./dist
# Start
CMD ["node", "dist/server.js"]
# Result: 100MB (12x smaller!)
How it works:
- Builder stage: Has all build tools, compiles code
- Runtime stage: Only has what's needed to run
- COPY --from: Copies artifacts between stages
Real-World Example: Node.js API
Before (Single Stage)
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install # Installs dev dependencies too
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Image size: 1.2GB
# Layers: 15
# Vulnerabilities: 47
After (Multi-Stage)
# Stage 1: Dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # Install all dependencies for build
COPY . .
RUN npm run build
# Stage 3: Runtime
FROM node:18-alpine
WORKDIR /app
# Copy only what's needed
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Image size: 100MB
# Layers: 8
# Vulnerabilities: 12
Results:
- Size: 1.2GB → 100MB (92% smaller)
- Build time: 3min → 1min (cached layers)
- Vulnerabilities: 47 → 12 (less attack surface)
Go Application (Most Dramatic)
# Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server
# Runtime stage - scratch (empty!)
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]
# Image size: 8MB (from 800MB!)
# 100x smaller!
Why so small:
- Go compiles to static binary
scratchimage is empty (0MB)- Only contains the binary
- No OS, no shell, no nothing
Python Application
# Build stage
FROM python:3.11 AS builder
WORKDIR /app
# Install dependencies
COPY requirements.txt ./
RUN pip install --user -r requirements.txt
# Runtime stage
FROM python:3.11-slim
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /root/.local /root/.local
# Copy application
COPY . .
# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
# Image size: 150MB (from 950MB)
React Application (with Nginx)
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage - serve with nginx
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/build /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# Image size: 25MB (from 600MB)
Optimizing Further
Use Alpine Images
# Regular: node:18 (900MB)
FROM node:18
# Alpine: node:18-alpine (150MB)
FROM node:18-alpine
# Slim: node:18-slim (200MB)
FROM node:18-slim
Use Distroless (Google)
# Build
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o server
# Runtime - distroless
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/server /server
CMD ["/server"]
# No shell, no package manager, minimal OS
# Even smaller than alpine
Layer Caching
# BAD - Changes in source invalidate npm install
FROM node:18-alpine
WORKDIR /app
COPY . . # Copies everything
RUN npm install # Re-runs if ANY file changes
RUN npm run build
# GOOD - Separate dependencies from source
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./ # Copy only package files
RUN npm ci # Cached unless package.json changes
COPY . . # Source changes don't invalidate cache
RUN npm run build
Multiple Target Stages
# Base stage
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
# Development stage
FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
# Build stage
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=base /app/package*.json ./
COPY --from=builder /app/dist ./dist
RUN npm ci --only=production
CMD ["node", "dist/server.js"]
# Build specific stage:
# docker build --target development -t app:dev .
# docker build --target production -t app:prod .
Real Project: Full Stack App
# Stage 1: Build frontend
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Build backend
FROM node:18-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci
COPY backend/ ./
RUN npm run build
# Stage 3: Runtime
FROM node:18-alpine
WORKDIR /app
# Copy backend
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=backend-builder /app/backend/node_modules ./node_modules
# Copy frontend build
COPY --from=frontend-builder /app/frontend/build ./public
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Serves API + static frontend
# Image size: 120MB (from 1.5GB monorepo)
Security Benefits
# Single-stage (insecure)
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Contains:
# - npm (can install malicious packages)
# - git (can clone malicious repos)
# - Build tools (gcc, python)
# - Source code (intellectual property)
# - Dev dependencies (vulnerabilities)
# Multi-stage (secure)
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
# Contains:
# - Only runtime (node)
# - Only production dependencies
# - Only compiled code
# - No build tools
# - No source code
Scan results:
# Before
docker scan myapp:single-stage
# Vulnerabilities: 47 (12 high, 35 medium)
# After
docker scan myapp:multi-stage
# Vulnerabilities: 12 (2 high, 10 medium)
Common Patterns
Pattern 1: Install Dependencies Separately
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:18-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]
Pattern 2: Build Then Copy Artifacts
FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
Pattern 3: Multiple Builds
FROM golang:1.21 AS api-builder
WORKDIR /app/api
COPY api/ ./
RUN go build -o api
FROM node:18-alpine AS web-builder
WORKDIR /app/web
COPY web/ ./
RUN npm run build
FROM alpine:latest
COPY --from=api-builder /app/api/api /bin/api
COPY --from=web-builder /app/web/build /var/www
CMD ["/bin/api"]
Docker Compose with Multi-Stage
# docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
target: production # Build production stage
ports:
- "3000:3000"
environment:
NODE_ENV: production
app-dev:
build:
context: .
target: development # Build development stage
ports:
- "3000:3000"
volumes:
- ./src:/app/src # Mount source for hot reload
environment:
NODE_ENV: development
Build Arguments
FROM node:18-alpine AS builder
# Build argument
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
# Build with custom argument
docker build --build-arg NODE_ENV=staging -t app:staging .
.dockerignore
# .dockerignore
node_modules
npm-debug.log
.git
.env
.env.local
*.md
.vscode
.idea
coverage
.cache
dist
build
.next
# Keeps these out of build context
# Faster builds, smaller images
Testing Images
# Build
docker build -t myapp:latest .
# Check size
docker images myapp:latest
# REPOSITORY TAG SIZE
# myapp latest 100MB
# Run
docker run -p 3000:3000 myapp:latest
# Shell into container (if it has one)
docker run -it myapp:latest sh
# Scan for vulnerabilities
docker scan myapp:latest
Before and After Comparison
Single-Stage Dockerfile (Before)
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
Metrics:
- Build time: 3m 45s
- Image size: 1.2GB
- Layers: 15
- Push time: 4m 30s
- Pull time: 5m 20s
- Vulnerabilities: 47
Multi-Stage Dockerfile (After)
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
Metrics:
- Build time: 1m 15s (cached: 10s)
- Image size: 100MB
- Layers: 8
- Push time: 25s
- Pull time: 30s
- Vulnerabilities: 12
Improvements:
- 92% smaller image
- 67% faster builds
- 90% faster deploys
- 74% fewer vulnerabilities
Common Mistakes
Mistake 1: Not Using .dockerignore
# Without .dockerignore
COPY . .
# Copies node_modules, .git, etc. (slow!)
# With .dockerignore
COPY . .
# Copies only what's needed (fast!)
Mistake 2: Installing Dev Dependencies in Production
# BAD
RUN npm install # Installs devDependencies
# GOOD
RUN npm ci --only=production # Production only
Mistake 3: Not Using Alpine
# BAD - 900MB
FROM node:18
# GOOD - 150MB
FROM node:18-alpine
Mistake 4: Copying node_modules
# BAD
COPY . . # Copies local node_modules
# GOOD
# .dockerignore: node_modules
COPY . . # Doesn't copy node_modules
RUN npm ci # Fresh install
Mistake 5: Wrong Order of Operations
# BAD - Changes in source invalidate everything
COPY . .
RUN npm install
RUN npm run build
# GOOD - Changes in source don't invalidate install
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CI/CD Integration
# GitHub Actions
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build image
run: docker build -t myapp:latest .
- name: Check size
run: docker images myapp:latest
- name: Scan for vulnerabilities
run: docker scan myapp:latest
- name: Push to registry
run: |
docker tag myapp:latest registry.example.com/myapp:latest
docker push registry.example.com/myapp:latest
The Bottom Line
Multi-stage builds separate build tools from runtime. Result: 10x smaller images.
Use multiple FROM statements - one for building, one for running.
Use Alpine or Distroless - minimal base images.
Copy only artifacts - not source code or build tools.
Add .dockerignore - keep build context small.
Our image went from 1.2GB to 100MB with multi-stage builds. Deployments: 5 minutes → 30 seconds. One Dockerfile change, massive improvement.
Add multi-stage builds today. Separate build and runtime. Your deploys will be 10x faster.