Docker Multi-Stage Builds for Smaller Images
Master Docker multi-stage builds to create minimal, secure production images. Learn techniques for Node.js, Go, Python, and more.
A typical Node.js development image can be 1GB+. A well-crafted production image? Under 50MB. Multi-stage builds are the key to creating minimal, secure Docker images without sacrificing developer experience.
The Problem with Single-Stage Builds
# ❌ Single-stage build - includes everything
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
This image contains:
- Full Node.js runtime with npm
- All development dependencies (TypeScript, ESLint, test frameworks)
- Source files we don’t need
- Build cache and artifacts
Result: ~1.2GB image with a large attack surface.
Multi-Stage Build Fundamentals
Multi-stage builds use multiple FROM statements. Each stage starts fresh, and you can copy artifacts between stages:
# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
Better, but we can do much better.
Optimized Node.js Multi-Stage Build
# ═══════════════════════════════════════════════════════════
# Stage 1: Dependencies
# ═══════════════════════════════════════════════════════════
FROM node:20-alpine AS deps
WORKDIR /app
# Install dependencies for native modules
RUN apk add --no-cache libc6-compat
# Copy only package files first (better layer caching)
COPY package.json package-lock.json ./
# Install ALL dependencies (including devDependencies for build)
RUN npm ci
# ═══════════════════════════════════════════════════════════
# Stage 2: Builder
# ═══════════════════════════════════════════════════════════
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependencies from previous stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Remove devDependencies for production
RUN npm prune --production
# ═══════════════════════════════════════════════════════════
# Stage 3: Production
# ═══════════════════════════════════════════════════════════
FROM node:20-alpine AS production
# Security: run as non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 appuser
WORKDIR /app
# Set production environment
ENV NODE_ENV=production
ENV PORT=3000
# Copy only what we need
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Start application
CMD ["node", "dist/index.js"]
Result: ~150MB with only production dependencies and proper security.
Ultra-Minimal with Distroless
For even smaller images, use Google’s distroless base:
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production
# Production stage - distroless
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
ENV NODE_ENV=production
CMD ["dist/index.js"]
Result: ~130MB with no shell, no package manager — minimal attack surface.
Go Application: From 1GB to 10MB
Go’s static compilation makes it perfect for minimal images:
# ═══════════════════════════════════════════════════════════
# Stage 1: Build
# ═══════════════════════════════════════════════════════════
FROM golang:1.22-alpine AS builder
# Install certificates for HTTPS
RUN apk add --no-cache ca-certificates git
WORKDIR /app
# Download dependencies first (better caching)
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags='-w -s -extldflags "-static"' \
-o /app/server ./cmd/server
# ═══════════════════════════════════════════════════════════
# Stage 2: Production (scratch)
# ═══════════════════════════════════════════════════════════
FROM scratch
# Copy certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy binary
COPY --from=builder /app/server /server
# Non-root user (numeric for scratch)
USER 65534
EXPOSE 8080
ENTRYPOINT ["/server"]
Result: ~10MB — literally just your binary and CA certificates!
Python Application
Python requires more care since it’s interpreted:
# ═══════════════════════════════════════════════════════════
# Stage 1: Build dependencies
# ═══════════════════════════════════════════════════════════
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ═══════════════════════════════════════════════════════════
# Stage 2: Production
# ═══════════════════════════════════════════════════════════
FROM python:3.12-slim AS production
# Security: non-root user
RUN useradd --create-home --shell /bin/bash appuser
WORKDIR /app
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy application code
COPY --chown=appuser:appuser . .
USER appuser
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Build Target Selection
Use targets to build specific stages:
# Base stage with common setup
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
# Development stage
FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
# Production dependencies
FROM base AS prod-deps
RUN npm ci --only=production
# Build stage
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
Build specific targets:
# Development
docker build --target development -t myapp:dev .
# Production
docker build --target production -t myapp:prod .
Layer Caching Best Practices
Order your Dockerfile from least to most frequently changing:
FROM node:20-alpine
WORKDIR /app
# 1. System dependencies (rarely change)
RUN apk add --no-cache libc6-compat
# 2. Package files (change when adding dependencies)
COPY package.json package-lock.json ./
# 3. Install dependencies (cached unless package files change)
RUN npm ci
# 4. Application code (changes frequently)
COPY . .
# 5. Build (runs on every code change)
RUN npm run build
Security Hardening
Non-Root User
# Create user in build stage
FROM node:20-alpine AS builder
# ... build steps ...
FROM node:20-alpine AS production
# Create user with specific UID/GID
RUN addgroup -g 1001 -S nodejs && \
adduser -S -u 1001 -G nodejs appuser
WORKDIR /app
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
USER appuser
Read-Only Filesystem
# In docker-compose.yml or docker run
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
Scan for Vulnerabilities
# Build and scan
docker build -t myapp:latest .
docker scout cves myapp:latest
# Or use Trivy
trivy image myapp:latest
.dockerignore
Always include a .dockerignore to exclude unnecessary files:
# .dockerignore
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.git
.gitignore
.dockerignore
README.md
LICENSE
.env*
*.md
test/
tests/
coverage/
.nyc_output/
.github/
docs/
*.log
.DS_Store
Image Size Comparison
| Stage Strategy | Node.js App | Go App |
|---|---|---|
| Single stage (node:20) | ~1.2 GB | ~1.1 GB |
| Single stage (alpine) | ~200 MB | ~300 MB |
| Multi-stage (alpine) | ~150 MB | ~50 MB |
| Multi-stage (distroless) | ~130 MB | ~15 MB |
| Multi-stage (scratch) | N/A | ~10 MB |
Key Takeaways
- Separate build and runtime — don’t ship compilers
- Use alpine or distroless for minimal base images
- Order layers by change frequency for better caching
- Run as non-root — always
- Use
.dockerignoreto exclude unnecessary files - Scan images for vulnerabilities before deploying
- Use build targets for dev/prod variants
“The best container is the smallest container that still works. Every byte you don’t ship is a byte that can’t be exploited.”