SUBID DASPORTFOLIO / 2026
000

SUBID

PORTRAIT // ORIGINAL[ 1 / 7 ]
WORK // RIDENOWW[ 2 / 7 ]
WORK // DEED POLL[ 3 / 7 ]
PORTRAIT // CYBERNETIC[ 4 / 7 ]
PORTRAIT // HOLOGRAM[ 5 / 7 ]
PORTRAIT // SKETCH[ 6 / 7 ]
PORTRAIT // GLITCH[ 7 / 7 ]
SUBID DAS
Back to Blog

Docker Multi-Stage Builds: Reduce Image Size by 90% with Smart Layering

9 min readSubid Das
dockerdevopscontainerizationperformanceoptimization

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

  1. Stage 1 (Builder): Installs only production dependencies
  2. Stage 2 (Compiler): Builds code (TypeScript → JavaScript) but doesn't ship
  3. 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 builder labels 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.

About the author

Subid Das is a cloud native engineer and open source contributor. Find more articles onthe blog.

Open to freelance, full-time, and interesting problems.

LET'S CONNECT