Docker Image Optimization: A Practical, Step-by-Step Guide (With Real Examples)
Learn how to shrink Docker images, inspect layers, use multi-stage builds, and fix common bloat. Practical steps with real examples — no fluff.

Most teams don't notice their Docker image size until deployments feel slow, containers take forever to start, CI pipelines drag, or someone asks: "Why is this 800MB?"
The truth is simple: large images aren't always wrong — but they're often unexamined.
This guide shows you how to inspect what's making your image big, why standard advice doesn't always work, practical steps to reduce size safely, and when shrinking further stops making sense.
Step 1: Measure Before You Touch Anything
Before optimizing, inspect the image. You need data, not guesses.
Check Image Size
docker imagesThis gives you the total size of each image and its tag.
Inspect Layers
docker history your-image-nameThis shows each layer, which instruction created it, and how much space it added. Example output:
IMAGE CREATED BY SIZE
abc123 CMD ["node" "dist/index.js"] 0B
def456 RUN npm ci --omit=dev #3 142MB ← dependency bloat
ghi789 COPY --from=builder /app/package*.json ./ 2.1kB
jkl012 COPY --from=builder /app/dist ./dist 450kB
...
Spot the fat layers. Those are your optimization targets.
Think of Docker images like stacked transparent sheets. Every RUN, COPY, or ADD creates a new layer. You can't remove weight from a lower layer without rebuilding. If you don't inspect layers, you're optimizing blindly.
Step 2: Identify the Real Source of Bloat
Common reasons images grow large:
1. Heavy Base Image
Example:
FROM node:22That's a full OS plus runtime — often 300–400MB before you add anything.
Try:
FROM node:22-slimOr consider distroless images for production runtime. Sometimes your base image alone is half the problem.
2. Dependencies Explosion
Bad pattern:
COPY . .
RUN npm installThis copies everything and installs dev dependencies.
Better:
COPY package*.json ./
RUN npm install --productionEven better:
RUN npm ci --omit=dev(Use --omit=dev — --only=production is deprecated.) Dependency trees are often bigger than your application code.
3. Build Tools Left in Production
Bad pattern:
RUN apt-get update
RUN apt-get install -y build-essentialNow your production container includes compilers. Use multi-stage builds instead.
Step 3: Use Multi-Stage Builds Properly
Multi-stage builds separate the build environment from the runtime. Build tools stay in stage one; only compiled output moves to the final image.
Example:
1# Stage 1 - Build
2FROM node:22 AS builder
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci
6COPY . .
7RUN npm run build
8
9# Stage 2 - Runtime
10FROM node:22-slim
11WORKDIR /app
12COPY --from=builder /app/dist ./dist
13COPY --from=builder /app/package*.json ./
14RUN npm ci --omit=dev
15CMD ["node", "dist/index.js"]What this does:
- Build tools stay in stage 1
- Only compiled output moves to runtime
- Final image is much smaller — often 30–60% reduction
Layer ordering tip: Put rarely-changing instructions first (COPY package*.json, RUN npm ci), then frequently-changing ones (COPY . ., RUN npm run build). Docker caches layers; good ordering means faster rebuilds when you change code.
Step 4: Clean in the Same Layer
Docker keeps layers immutable. If cleanup happens in a different layer, the cache still exists underneath — like deleting a file but keeping it in version history.
Bad:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*Good:
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*Combine install and cleanup in a single layer so the removed data never persists.
Step 5: Use .dockerignore (Most Teams Forget This)
If you don't define .dockerignore, Docker copies everything from your build context.
Common mistakes:
.gitnode_moduleslogs- Local test files
- Build artifacts
Example .dockerignore:
.git
.gitignore
node_modules
npm-debug.log
*.log
.env
.env.*
dist
build
.dockerignore
Dockerfile*
docker-compose*
README.md
.vscode
.idea
__pycache__
*.pyc
.pytest_cache
coverage
You should never ship your entire project folder. A good .dockerignore often yields the fastest, easiest size reduction.
Step 6: Analyze What's Actually Inside
You can inspect a running container to see where space is used:
docker run -it your-image shInside the container:
du -sh *This helps you see:
- Which folders are huge
- Which libraries are unnecessary
- Whether debug tools are included
Sometimes the biggest folder isn't what you expect.
Pro tip: Use dive (dive your-image-name) to explore each layer interactively. It shows exactly which files landed in which layer and how much space they use.
Step 7: Consider Distroless or Minimal Runtime
Distroless images remove:
- Shell
- Package manager
- Debug utilities
This reduces:
- Size
- Attack surface
- Security exposure
Use distroless only when:
- You don't need shell access
- Your debugging strategy is mature
Distroless Node example (build in previous stage, copy output only):
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=builder /app/dist ./
CMD ["index.js"]Distroless offers -debug variants (e.g. gcr.io/distroless/nodejs22-debian12:debug) that include BusyBox — use those when you need to shell in. Optimization should not hurt maintainability. If you rely on docker exec for troubleshooting, a minimal image with a shell may be the right tradeoff.
When You Can't Shrink Further
Sometimes you hit a wall.
Reasons:
- Framework is inherently heavy
- Language runtime is large
- ML libraries are required
- Business logic demands certain dependencies
At that point, the question shifts from "How small can we make it?" to "Is this image responsible for too much?"
Consider:
- Splitting background workers from the API
- Moving heavy tools to separate services
- Avoiding bundling multiple responsibilities
Optimization isn't only about Dockerfile tricks. It's about architectural clarity.
A Simple Review Checklist
Before shipping to production, ask:
- Is the base image minimal?
- Are build tools excluded from runtime?
- Are dev dependencies removed?
- Is
.dockerignoreconfigured? - Are cleanup commands in the same layer?
- Is the image under 400MB without strong justification?
If not, review it.
Practical Example: Before vs After
Initial image:
- Base: Full Node image
- Included dev dependencies
- Included build tools
- Size: ~720MB
After optimization:
- Slim runtime image (
node:22-slim) - Multi-stage build
- Production dependencies only
- Cleaned cache
- Proper
.dockerignore - Size: ~280MB
Same functionality. Cleaner container. Faster startup.
Beyond Node: Python & Go Quick Wins
Python: Use slim bases and avoid caching pip's download cache in the image:
FROM python:3.12-slim
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .Go: Static binaries let you use an empty base — often under 20MB:
1# Build stage
2FROM golang:1.22-alpine AS builder
3WORKDIR /app
4COPY go.mod go.sum ./
5RUN go mod download
6COPY . .
7RUN CGO_ENABLED=0 go build -o /app/server .
8
9# Runtime — scratch has nothing
10FROM scratch
11COPY --from=builder /app/server /server
12CMD ["/server"]What is scratch? It's Docker's special empty base image — no OS, no shell, no utilities, zero files. Your final image contains only what you COPY into it (your binary). That means minimal size and attack surface, but you can't docker exec into the container — there's no shell. Go works well with scratch because CGO_ENABLED=0 produces a statically linked binary with no external dependencies. Other languages that produce dynamic binaries (Python, Node, Java) need a real base image.
Final Thought
Docker image optimization isn't about chasing the smallest possible number.
It's about:
- Faster deployments
- Predictable startup time
- Cleaner runtime environment
- Reduced attack surface
- Operational discipline
Large images aren't always wrong. But unreviewed images usually are. If your team hasn't inspected docker history in months, there's probably easy improvement waiting.


