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:

  1. Builder stage: Has all build tools, compiles code
  2. Runtime stage: Only has what's needed to run
  3. 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
  • scratch image 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.