Docker Multi-Stage Builds: Reduce Image Size by 90% with Smart Layering
Docker images can balloon to 500MB+ when built naively. Multi-stage builds reduce this to 50MB by separating build dependencies from runtime artifacts. This guide shows you how.
The Problem: Bloated Images
# ❌ Bad: Everything in one stage
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
Final image: 450MB
- Node.js compiler/build tools: 200MB
- npm cache: 150MB
- Source code: 50MB
- Dependencies: 50MB
You ship the compiler to production, which is wasteful.
The Solution: Multi-Stage Builds
# Stage 1: Builder
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Compile
FROM node:18 AS compiler
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Runtime (minimal)
FROM node:18-alpine
WORKDIR /app
COPY --from=compiler /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]
Final image: 85MB (81% reduction!)
Why This Works
- Stage 1 (Builder): Installs only production dependencies
- Stage 2 (Compiler): Builds code (TypeScript → JavaScript) but doesn't ship
- Stage 3 (Runtime): Copies only built artifacts + runtime deps
The compiler, dev dependencies, and build cache never touch the final image.
Advanced Pattern: Frontend Builds
# Node stage: Build React/Next.js app
FROM node:18 AS frontend
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Nginx stage: Serve static files
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend /app/.next/static /usr/share/nginx/html
COPY --from=frontend /app/public /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Image size: 30MB (vs 600MB with Node.js runtime)
Layer Caching Optimization
Docker caches layers. Order matters:
# ❌ Bad: Changes to code invalidate dependency cache
FROM node:18
WORKDIR /app
COPY . . # Code changes here...
RUN npm ci # ...invalidates cache here
# ✅ Good: Dependencies cached longer
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm ci # Only runs when package.json changes
COPY . . # Code changes don't affect cache
With good ordering, rebuilds drop from 5 minutes to 10 seconds.
Production-Ready Pattern
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm run test || true
FROM node:18-alpine
WORKDIR /app
# Security: Non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# Copy artifacts
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json .
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
CMD ["node", "dist/index.js"]
Security additions:
- Non-root user (prevents escape attacks)
- Health checks (container orchestration readiness)
- File ownership (prevents privilege escalation)
Size Reduction Techniques
1. Alpine Linux
FROM node:18-alpine
# Instead of:
# FROM node:18
# Saves: 200MB
Alpine is 100MB vs Node.js 300MB+.
2. Remove Unnecessary Files
RUN npm ci && \
npm cache clean --force && \
rm -rf /tmp/* /var/cache/* /var/log/*
Clears cache after install. Saves 50-100MB.
3. Distroless Images
FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM gcr.io/distroless/nodejs18-debian11
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
WORKDIR /app
CMD ["dist/index.js"]
Distroless: 50MB vs Alpine: 120MB. No shell, no package manager—minimal attack surface.
Benchmarking Impact
# Before multi-stage
$ docker build -t app:old .
$ docker images app:old
REPOSITORY TAG SIZE
app old 450MB
# After multi-stage
$ docker build -t app:new .
$ docker images app:new
REPOSITORY TAG SIZE
app new 85MB
# Pull time improvement (1 Mbps connection)
Before: 450MB ÷ 1 Mbps = 6 minutes
After: 85MB ÷ 1 Mbps = 68 seconds
Checklist
- Separate build stage from runtime
- Use
AS builderlabels for clarity - Order Dockerfile: dependencies, code, build
- Use Alpine or distroless base images
- Clean npm cache after install
- Run tests in builder stage
- Copy only necessary artifacts (dist/, node_modules)
- Add health checks
- Use non-root user
- Test locally with
docker build --progress=plain
Common Mistakes
❌ Copying entire node_modules from builder (defeats purpose)
❌ Using latest tags (no reproducibility)
❌ Forgetting layer caching order
❌ Installing dev dependencies in production stage
Conclusion
Multi-stage builds are Docker's best-kept secret. A well-structured Dockerfile reduces image size by 80-90%, cuts deployment time, and improves security.
Start with the pattern above. Measure with docker images before/after. The difference is dramatic.

