Difficulty: Intermediate
What are Docker multi-stage builds? How do you optimize Docker images for production?
Multi-stage builds use multiple FROM statements in a single Dockerfile. Each FROM starts a new build stage. You can selectively copy artifacts from one stage to another, leaving behind build tools and intermediate files. This dramatically reduces image size.
For example, a TypeScript app needs the full Node.js environment plus dev dependencies to build, but only needs the runtime and production dependencies to run. A multi-stage build compiles in stage 1 and copies only the compiled output to a minimal stage 2.
Image optimization strategies: 1. Use small base images (alpine variants are 5-10x smaller) 2. Minimize layers (combine RUN commands with &&) 3. Leverage layer caching (copy package.json before source code) 4. Use .dockerignore to exclude unnecessary files 5. Do not run as root (USER node) 6. Use multi-stage builds to exclude build tools 7. Pin exact versions for reproducibility
Smaller images mean faster pulls, less storage, smaller attack surface, and quicker deployments.
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Save production node_modules
RUN cp -R node_modules /prod_modules
# Install all dependencies (including dev)
RUN npm ci
# Stage 2: Build TypeScript
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Output: dist/ directory
# Stage 3: Production runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy only production dependencies
COPY --from=deps /prod_modules ./node_modules
# Copy only compiled output
COPY --from=builder /app/dist ./dist
COPY package.json ./
# Security: don't run as root
RUN addgroup -g 1001 -S appgroup
RUN adduser -S appuser -u 1001 -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Result: ~150MB instead of ~900MB
Three stages: deps (install), builder (compile), runner (runtime). The final image only has production node_modules and compiled JS. Build tools and source TypeScript are excluded.
# BAD: Busts cache on every source change
FROM node:20-alpine
WORKDIR /app
COPY . . # Any file change invalidates this layer
RUN npm ci # Reinstalls deps every time!
RUN npm run build
# GOOD: Leverage layer caching
FROM node:20-alpine
WORKDIR /app
# Step 1: Copy only dependency files
COPY package.json package-lock.json ./
# Step 2: Install dependencies (cached if package.json unchanged)
RUN npm ci
# Step 3: Copy source code
COPY . .
# Step 4: Build (only runs if source changed)
RUN npm run build
# Docker caches each layer. If package.json hasn't changed,
# npm ci is SKIPPED (uses cached layer) = much faster builds
# Combine RUN commands to reduce layers
# BAD: 3 layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# GOOD: 1 layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
Copy package.json first, then npm ci, then source code. This way, dependency installation is cached unless package.json changes. Combining RUN commands reduces image layers.
# 1. Use specific image tags (not :latest)
FROM node:20.11.1-alpine3.19
# Reproducible builds, no surprise updates
# 2. Non-root user
USER node
# Or create a custom user:
RUN addgroup -S app && adduser -S app -G app
USER app
# 3. Read-only filesystem where possible
docker run --read-only --tmpfs /tmp myapp
# 4. Scan for vulnerabilities
docker scout cves myapp:latest
# Or: trivy image myapp:latest
# Or: snyk container test myapp:latest
# 5. Use .dockerignore
# .dockerignore
node_modules
.git
.env
*.md
tests/
coverage/
.github/
# 6. Health check in Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# 7. No secrets in build args or layers
# BAD: secret visible in image history
RUN echo "secret" > /app/.env
# GOOD: pass secrets at runtime
docker run -e JWT_SECRET=secret myapp
# Or use Docker secrets / BuildKit secrets
Pin image versions for reproducibility. Run as non-root to limit container escape damage. Scan images for vulnerabilities before deployment. Never bake secrets into images.
Multi-Stage Builds, Image Optimization, Layer Caching, Docker Best Practices, Security