DEV Community

Cover image for Your Docker Images Are Bigger Than They Need to Be
Polliog
Polliog

Posted on

Your Docker Images Are Bigger Than They Need to Be

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Looks innocent, right?

Let's check the size:

$ docker images
REPOSITORY          TAG       SIZE
my-app              latest    1.2GB
Enter fullscreen mode Exit fullscreen mode

1.2GB for a simple Express.js API. 🤦‍♂️


Problem #1: Using the Wrong Base Image

FROM node:18
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Size: 170MB (5x smaller!)

Option 2: Slim

FROM node:18-slim
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Fix for Alpine:

FROM node:18-alpine

# Install build dependencies for native modules
RUN apk add --no-cache python3 make g++
Enter fullscreen mode Exit fullscreen mode

Problem #2: Copying Unnecessary Files

COPY . .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Before:

$ du -sh .
450MB   .
Enter fullscreen mode Exit fullscreen mode

After:

$ du -sh . --exclude=node_modules --exclude=.git
12MB    .
Enter fullscreen mode Exit fullscreen mode

Saved: 438MB just by ignoring junk.


Problem #3: Installing Dev Dependencies

RUN npm install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or with newer npm:

RUN npm ci --omit=dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Saved: 140MB


Problem #4: Not Using Multi-Stage Builds

Here's the game-changer: multi-stage builds.

The concept:

  1. Use a "builder" stage with all build tools
  2. 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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. See Layer Breakdown

docker history my-app:latest
Enter fullscreen mode Exit fullscreen mode

This shows each layer and its size.

3. Use Dive (Visual Inspector)

# Install dive
brew install dive

# Analyze image
dive my-app:latest
Enter fullscreen mode Exit fullscreen mode

Dive shows:

  • Layer-by-layer size
  • Files added in each layer
  • Wasted space

4. Scan for Vulnerabilities

docker scan my-app:latest
Enter fullscreen mode Exit fullscreen mode

Or use Trivy:

trivy image my-app:latest
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Why it's bad:

  • latest changes over time (non-reproducible builds)
  • Can break unexpectedly

Do this instead:

FROM node:18.17.0-alpine
Enter fullscreen mode Exit fullscreen mode

Pin to specific versions.

❌ Mistake #3: Installing Unnecessary Tools

RUN apt-get install -y vim curl wget git
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After optimization:

REPOSITORY    TAG       SIZE
my-app        v2        120MB
Enter fullscreen mode Exit fullscreen mode

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:

Security:

  • Trivy - Vulnerability scanner
  • Snyk - Container security

Base Images:

Learning:


TL;DR - The Quick Version

5 steps to 10x smaller Docker images:

  1. Use Alpine or Slim base images
   FROM node:18-alpine  # Not node:18
Enter fullscreen mode Exit fullscreen mode
  1. Add .dockerignore
   node_modules
   .git
   *.test.js
Enter fullscreen mode Exit fullscreen mode
  1. Install only production dependencies
   RUN npm ci --only=production
Enter fullscreen mode Exit fullscreen mode
  1. Use multi-stage builds
   FROM node:18-alpine AS builder
   # ... build here ...
   FROM node:18-alpine
   COPY --from=builder ...
Enter fullscreen mode Exit fullscreen mode
  1. Chain RUN commands
   RUN apt-get update && \
       apt-get install -y wget && \
       rm -rf /var/lib/apt/lists/*
Enter fullscreen mode Exit fullscreen mode

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)