Docker images can quickly spiral out of control. A Node.js application I was working on had ballooned to 1.2GB—far too large for efficient CI/CD pipelines and cloud deployments. After implementing multi-stage builds and optimization techniques, I reduced it to just 180MB. Here's exactly how I did it.
Why Docker Image Size Matters
Large Docker images create cascading problems:
Slower deployments—transferring 1.2GB across networks is painful
Higher storage costs on container registries
Increased attack surface—more layers, more potential vulnerabilities
Longer startup times in CI/CD pipelines
Wasted resources in Kubernetes clusters
During my production deployments, I watched image pulls timeout regularly. The solution wasn't just better infrastructure—it was better image optimization.
The Problem: Bloated Base Stages
My original Dockerfile looked like this (simplified):
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
The node:18 image weighs ~1GB on its own. Adding node_modules made it worse—npm dependencies add hundreds of MB. Every build layer was creating a massive intermediate image.
Solution 1: Multi-Stage Builds
This is the game-changer. Multi-stage builds use separate build environments and runtime environments:
Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
Copy only built artifacts and prod dependencies from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
What changed:
Using alpine variants—Alpine Linux is 5MB vs. 900MB for standard Node images
Builder stage includes everything (compilers, build tools, dev dependencies)
Runtime stage copies only what's needed (compiled code and production dependencies)
Dev dependencies never make it into production
Result: 850MB → 450MB
Solution 2: Dependency Optimization
While multi-stage builds helped massively, I went deeper:
Use npm ci instead of npm install for reproducibility
RUN npm ci --only=production
Prune unnecessary files
RUN npm prune --production
Remove npm cache
RUN rm -rf ~/.npm
I also audited my package.json:
Moved unused dependencies to devDependencies
Replaced heavy packages (moment.js at 70KB) with lightweight alternatives (date-fns)
Removed deprecated packages from node_modules
Result: 450MB → 280MB
Solution 3: Minimalist Runtime Layer
Instead of node:18-alpine, I explored distroless images:
Stage 2: Runtime with distroless
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["dist/index.js"]
Distroless images contain only your application and runtime—no package manager, no shell, no unnecessary tools. They're ~20MB vs. Alpine's 5MB base.
Result: 280MB → 180MB
Solution 4: Layer Caching & .dockerignore
Optimizing the Dockerfile for rebuild speed:
FROM node:18-alpine AS builder
WORKDIR /app
Copy package files first (cache this layer)
COPY package*.json ./
RUN npm ci --only=production
Copy source code after (changes more frequently)
COPY . .
RUN npm run build
And a proper .dockerignore:
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
dist
build
COVERAGE
.DS_Store
.vscode
.idea
TEST_*.log
Benefits:
Layers are cached independently—if package.json doesn't change, npm install is skipped
Removing unnecessary files from the context reduces build time and image size
Final Multi-Stage Dockerfile
Here's the complete optimized version:
Stage 1: Dependencies & Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
npm cache clean --force
COPY . .
RUN npm run build
Stage 2: Runtime (distroless)
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["dist/index.js"]
Performance Gains
Reduction Summary:
Initial: 1.2GB
After multi-stage: 450MB
After dependency cleanup: 280MB
After distroless: 180MB (85% reduction)
Downstream impact:
CI/CD pipeline time: 12min → 4min
Registry storage: $240/month → $35/month
Kubernetes pod startup: 45s → 12s
Attack surface: 150+ packages removed
Production Tips
Use crane to inspect image layers:
go install github.com/google/go-containerregistry/cmd/crane@latest
crane export gcr.io/my-project/app:latest app.tar
tar -tzf app.tar | head -20
Scan for vulnerabilities in your runtime-only image
Monitor image size in your CI/CD—add warnings if it exceeds thresholds
Test distroless locally before production—some apps need /bin/sh for debugging
Conclusion
Containerization doesn't mean bloat. By combining multi-stage builds, dependency optimization, and distroless images, I achieved an 85% reduction in image size without sacrificing functionality. This isn't just about disk space—it's about faster deployments, lower costs, and more resilient infrastructure.
Start with multi-stage builds, then progressively optimize. The ROI compounds quickly.
Top comments (0)