I just inherited a Node.js project. The Docker image was 1.2GB.
After 30 minutes of optimization, I got it down to 120MB.
That's 10x smaller.
Here's what was wrong, how I fixed it, and why image size actually matters.
Why Image Size Matters
"Who cares? Storage is cheap!"
Sure, but:
1. Deploy Time
Pushing/pulling a 1.2GB image takes forever on CI/CD pipelines.
Real numbers from my experience:
- 1.2GB image: ~8 minutes to push to Docker Hub
- 120MB image: ~45 seconds
That's 10 minutes saved per deploy. If you deploy 5 times a day, that's 50 minutes/day wasted.
2. Security Surface Area
Bigger image = more packages = more CVEs (Common Vulnerabilities and Exposures).
Example:
# Before optimization
docker scan my-app:bloated
✗ 247 vulnerabilities found
# After optimization
docker scan my-app:optimized
✗ 18 vulnerabilities found
247 → 18 vulnerabilities just by removing unnecessary packages.
3. Cold Start Time (Serverless)
If you're running containers in AWS Lambda, Google Cloud Run, or similar:
- Larger images = slower cold starts
- AWS Lambda has a 10GB image size limit
- Google Cloud Run charges for memory used during container startup
4. Storage Costs
Docker Hub free tier: 1 private repo
If you have multiple images:
- 10 images × 1.2GB = 12GB
- 10 images × 120MB = 1.2GB
That's $5-10/month in registry costs (or hitting free tier limits).
The Culprits: What Makes Images Bloated?
Let me show you the before Dockerfile (real code from the project I inherited):
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
Looks innocent, right?
Let's check the size:
$ docker images
REPOSITORY TAG SIZE
my-app latest 1.2GB
1.2GB for a simple Express.js API. 🤦♂️
Problem #1: Using the Wrong Base Image
FROM node:18
This single line pulls the full Node.js image, which includes:
- Debian operating system
- npm
- yarn
- A bunch of build tools (gcc, g++, make, python)
- Man pages
- Documentation
Size: 900MB
The Fix: Use Alpine or Slim
Option 1: Alpine (smallest)
FROM node:18-alpine
Size: 170MB (5x smaller!)
Option 2: Slim
FROM node:18-slim
Size: 240MB (3.7x smaller)
Alpine vs Slim: Which to Choose?
| Feature | Alpine | Slim |
|---|---|---|
| Size | ~170MB | ~240MB |
| Base OS | Alpine Linux (musl libc) | Debian (glibc) |
| Compatibility | ⚠️ Some native modules break | ✅ Better compatibility |
| Security | ✅ Smaller attack surface | 🟡 Larger attack surface |
My recommendation: Start with Alpine. If you hit compatibility issues (native node modules), switch to Slim.
Common issue with Alpine:
# This will fail on Alpine
npm install bcrypt
# Error: `node-gyp` rebuild fails due to musl vs glibc
Fix for Alpine:
FROM node:18-alpine
# Install build dependencies for native modules
RUN apk add --no-cache python3 make g++
Problem #2: Copying Unnecessary Files
COPY . .
This copies everything in your project directory:
-
node_modules/(if you have it locally) -
.git/(entire Git history) README.md.env.example- Test files
- Source maps
- Documentation
The Fix: Use .dockerignore
Create a .dockerignore file:
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.example
docker-compose.yml
Dockerfile
.vscode
.idea
*.test.js
*.spec.js
coverage/
.DS_Store
Before:
$ du -sh .
450MB .
After:
$ du -sh . --exclude=node_modules --exclude=.git
12MB .
Saved: 438MB just by ignoring junk.
Problem #3: Installing Dev Dependencies
RUN npm install
This installs all dependencies, including:
- Testing libraries (Jest, Mocha)
- Linters (ESLint, Prettier)
- TypeScript compiler
- Webpack, Babel, and other build tools
You don't need these in production.
The Fix: Production-Only Install
RUN npm ci --only=production
Or with newer npm:
RUN npm ci --omit=dev
Why npm ci instead of npm install?
- Faster (uses package-lock.json directly)
- Deterministic (exact versions every time)
- Cleaner (removes existing node_modules first)
Size comparison:
# All dependencies
node_modules/: 280MB
# Production only
node_modules/: 140MB
Saved: 140MB
Problem #4: Not Using Multi-Stage Builds
Here's the game-changer: multi-stage builds.
The concept:
- Use a "builder" stage with all build tools
- Copy only the compiled artifacts to a minimal "runtime" stage
Example: TypeScript App
Before (single stage):
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
Size: 350MB (includes TypeScript, build tools, source files)
After (multi-stage):
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
Size: 180MB
What happened?
- Builder stage: Has TypeScript, dev dependencies, source files
- Runtime stage: Only has compiled JS and production dependencies
- Docker discards the builder stage after copying what we need
Real-World Example: Next.js
# Stage 1: Dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Runtime
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Size:
- Without multi-stage: 650MB
- With multi-stage: 180MB
Problem #5: Not Cleaning Up After Yourself
Even with Alpine and multi-stage builds, you can still bloat images by leaving temp files.
Bad Example:
RUN apt-get update && apt-get install -y wget
RUN wget https://example.com/large-file.tar.gz
RUN tar -xzf large-file.tar.gz
RUN rm large-file.tar.gz
Problem: Each RUN creates a layer. The rm command creates a new layer that says "delete this file," but the file still exists in a previous layer.
Image size: Still includes large-file.tar.gz!
The Fix: Chain Commands
RUN apt-get update && \
apt-get install -y wget && \
wget https://example.com/large-file.tar.gz && \
tar -xzf large-file.tar.gz && \
rm large-file.tar.gz && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
All in one RUN command = one layer.
Problem #6: Not Leveraging Layer Caching
Docker caches each layer. If nothing changed, it reuses the cached layer.
Bad Example:
COPY . .
RUN npm install
Problem: Every time you change any file (even a README), Docker invalidates the cache and reinstalls all npm packages.
The Fix: Copy package.json First
COPY package*.json ./
RUN npm ci
COPY . .
Now:
- If you change code: Only the last layer rebuilds (fast!)
- If you change dependencies: npm install runs again (necessary)
Build time comparison:
- Bad order: 3 minutes every time
- Good order: 10 seconds for code changes, 3 minutes only when dependencies change
The Optimized Dockerfile (Node.js Example)
Here's the final, optimized version:
# Stage 1: Dependencies
FROM node:18-alpine AS deps
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production && \
npm cache clean --force
# Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy dependencies from deps stage
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
# Copy application code
COPY --chown=nodejs:nodejs . .
# Use non-root user
USER nodejs
EXPOSE 3000
CMD ["node", "server.js"]
Size: 120MB
Security bonus: Runs as non-root user (best practice).
Advanced Optimization: Distroless Images
Google's "distroless" images contain only your app and runtime dependencies.
No shell, no package manager, nothing extra.
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Distroless runtime
FROM gcr.io/distroless/nodejs:18
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app .
CMD ["server.js"]
Size: 110MB
Trade-off: No shell means you can't docker exec -it into the container. Debugging is harder.
When to use distroless:
- Production only
- When security is paramount
- When you have good observability (logs, traces)
Real-World Comparison Table
| Optimization | Image Size | Build Time | Security |
|---|---|---|---|
| node:18 | 900MB | 4 min | 247 CVEs |
| + Alpine | 170MB | 3 min | 89 CVEs |
| + .dockerignore | 165MB | 2.5 min | 89 CVEs |
| + Prod deps only | 145MB | 2 min | 45 CVEs |
| + Multi-stage | 120MB | 1.5 min | 18 CVEs |
| + Distroless | 110MB | 1.5 min | 12 CVEs |
How to Audit Your Current Images
1. Check Current Size
docker images
2. See Layer Breakdown
docker history my-app:latest
This shows each layer and its size.
3. Use Dive (Visual Inspector)
# Install dive
brew install dive
# Analyze image
dive my-app:latest
Dive shows:
- Layer-by-layer size
- Files added in each layer
- Wasted space
4. Scan for Vulnerabilities
docker scan my-app:latest
Or use Trivy:
trivy image my-app:latest
Quick Wins Checklist
Before your next deploy, do this:
- [ ] Switch from `node:18` to `node:18-alpine`
- [ ] Add `.dockerignore` file
- [ ] Use `npm ci --only=production`
- [ ] Copy `package.json` before copying code
- [ ] Use multi-stage builds (if compiling TypeScript/Webpack)
- [ ] Chain RUN commands to reduce layers
- [ ] Run as non-root user
- [ ] Scan for vulnerabilities with `docker scan`
Time investment: 30 minutes
Result: 5-10x smaller images
Language-Specific Tips
Python
FROM python:3.11-alpine
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy code
COPY . .
CMD ["python", "app.py"]
Use --no-cache-dir to prevent pip from storing cache (saves ~50MB).
Go
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server
# Runtime (scratch = empty image!)
FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]
Go can compile to a static binary, so you can use FROM scratch (literally empty).
Size: 10-20MB for a full API!
Java
FROM eclipse-temurin:17-jdk-alpine AS builder
WORKDIR /app
COPY . .
RUN ./mvnw clean package
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
CMD ["java", "-jar", "app.jar"]
Use JRE (not JDK) in runtime. JDK includes compilers you don't need.
Savings: 300MB
Common Mistakes to Avoid
❌ Mistake #1: Running apt-get upgrade
RUN apt-get update && apt-get upgrade -y
Why it's bad:
- Makes builds non-deterministic (different packages on different days)
- Increases build time
- Can introduce breaking changes
Do this instead: Use a newer base image if you need updates.
❌ Mistake #2: Using latest Tag
FROM node:latest
Why it's bad:
-
latestchanges over time (non-reproducible builds) - Can break unexpectedly
Do this instead:
FROM node:18.17.0-alpine
Pin to specific versions.
❌ Mistake #3: Installing Unnecessary Tools
RUN apt-get install -y vim curl wget git
You don't need debugging tools in production images.
If you need to debug: Use docker exec with a debug image, or check logs.
Measuring Success
Before optimization:
REPOSITORY TAG SIZE
my-app v1 1.2GB
After optimization:
REPOSITORY TAG SIZE
my-app v2 120MB
Deploy time improvement:
- Before: 8 minutes
- After: 45 seconds
Storage costs (10 images, 30-day retention):
- Before: $15/month (Docker Hub)
- After: $2/month
Security vulnerabilities:
- Before: 247 CVEs
- After: 18 CVEs
Tools and Resources
Analysis:
- Dive - Layer analyzer
- Docker Slim - Automated optimizer
Security:
Base Images:
Learning:
TL;DR - The Quick Version
5 steps to 10x smaller Docker images:
- Use Alpine or Slim base images
FROM node:18-alpine # Not node:18
- Add .dockerignore
node_modules
.git
*.test.js
- Install only production dependencies
RUN npm ci --only=production
- Use multi-stage builds
FROM node:18-alpine AS builder
# ... build here ...
FROM node:18-alpine
COPY --from=builder ...
- Chain RUN commands
RUN apt-get update && \
apt-get install -y wget && \
rm -rf /var/lib/apt/lists/*
Your Turn
Challenge: Take your current Docker image and optimize it.
Post your before/after results in the comments! I want to see:
- Image size before/after
- What techniques you used
- How much time you saved
What's the biggest Docker image you've seen in production? My record is 4.2GB for a Python ML app. Drop your horror stories below! 👇
P.S. - If you want to see the full optimized Dockerfile for various frameworks (Next.js, NestJS, Django, etc.), let me know in the comments and I'll do a follow-up article.
Top comments (0)