Table of Contents
Large Docker images are a common problem in containerized applications. They slow down deployments, consume more storage, and increase security attack surface. Multi-stage builds solve this by separating the build environment from the runtime environment.
What Are Multi-Stage Builds?
Multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. Each FROM starts a new build stage, and you can selectively copy artifacts from one stage to another.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
Node.js Application Example
Hereβs a production-ready multi-stage Dockerfile for a Node.js application:
# ================================
# Stage 1: Dependencies
# ================================
FROM node:20-alpine AS deps
WORKDIR /app
# Install only production dependencies
COPY package*.json ./
RUN npm ci --only=production && \
npm cache clean --force
# ================================
# Stage 2: Builder
# ================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && \
npm run test
# ================================
# Stage 3: Runtime
# ================================
FROM node:20-alpine AS runtime
# Security: Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Copy only what's needed
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
USER nodejs
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
Size Comparison
| Image Type | Size |
|---|---|
| Single-stage (with devDependencies) | ~850 MB |
| Multi-stage (production only) | ~180 MB |
| Multi-stage (with distroless) | ~120 MB |
Python Application with Virtual Environment
# ================================
# Stage 1: Builder
# ================================
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 Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ================================
# Stage 2: Runtime
# ================================
FROM python:3.12-slim AS runtime
# Security: Create 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
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:create_app()"]
Go Application: Smallest Possible Image
Goβs static binaries are perfect for ultra-small images:
# ================================
# Stage 1: Builder
# ================================
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Download dependencies
COPY go.mod go.sum ./
RUN go mod download
# Build static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s" \
-o /app/server ./cmd/server
# ================================
# Stage 2: Runtime (scratch)
# ================================
FROM scratch
# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy binary
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Result: A ~10 MB image containing only your binary!
Vue.js/React Static Site
# ================================
# Stage 1: Builder
# ================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ================================
# Stage 2: Production (nginx)
# ================================
FROM nginx:alpine AS production
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Security headers and compression
RUN echo 'server_tokens off;' >> /etc/nginx/nginx.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
Advanced Patterns
Build Arguments for Flexibility
ARG NODE_VERSION=20
ARG ALPINE_VERSION=3.19
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS builder
# ...
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS runtime
# ...
# Build with custom versions
docker build --build-arg NODE_VERSION=18 -t myapp .
Caching Dependencies Effectively
FROM node:20-alpine AS deps
WORKDIR /app
# Copy only package files first (better caching)
COPY package.json package-lock.json ./
# This layer is cached unless package*.json changes
RUN npm ci
# Now copy source code (changes more frequently)
COPY . .
RUN npm run build
Conditional Stages for Development
# ================================
# Base stage
# ================================
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 build
# ================================
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build
# ================================
# Production runtime
# ================================
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"]
# Build specific stage
docker build --target development -t myapp:dev .
docker build --target production -t myapp:prod .
Security Best Practices
1. Use Non-Root Users
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
USER appuser
2. Minimize Attack Surface
# Use distroless for minimal images
FROM gcr.io/distroless/nodejs20-debian12
# Or scratch for static binaries
FROM scratch
3. Scan for Vulnerabilities
# Use Docker Scout or Trivy
docker scout cves myapp:latest
trivy image myapp:latest
Performance Tips
1. Order Layers by Change Frequency
# Least frequently changed β Most frequently changed
COPY package.json ./ # Changes rarely
RUN npm ci # Cached until package.json changes
COPY src/ ./src/ # Changes often
COPY public/ ./public/ # Changes sometimes
2. Use .dockerignore
# .dockerignore
node_modules
.git
.gitignore
*.md
.env*
Dockerfile*
docker-compose*
.github
coverage
.nyc_output
3. Parallelize Independent Stages
FROM node:20 AS frontend-builder
COPY frontend/ .
RUN npm run build
FROM golang:1.22 AS backend-builder
COPY backend/ .
RUN go build -o server
FROM alpine:3.19
COPY --from=frontend-builder /app/dist /www
COPY --from=backend-builder /go/server /server
Conclusion
Multi-stage builds are essential for production Docker images:
- Smaller images: Only include runtime dependencies
- Faster deployments: Less data to transfer
- Better security: Reduced attack surface
- Cleaner Dockerfiles: Logical separation of concerns
Start optimizing your images today and see the difference in your deployment pipeline!
Whatβs your Docker optimization secret? Share in the comments!
In-Article Ad
Dev Mode
Alex Chen
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.