DevOps & Platform
12 min readFebruary 17, 2026

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.

AJ
Ajeet Yadav
Platform & Cloud Engineer
Docker Image Optimization: A Practical, Step-by-Step Guide (With Real Examples)

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

bash
docker images

This gives you the total size of each image and its tag.

Inspect Layers

bash
docker history your-image-name

This 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:

dockerfile
FROM node:22

That's a full OS plus runtime — often 300–400MB before you add anything.

Try:

dockerfile
FROM node:22-slim

Or consider distroless images for production runtime. Sometimes your base image alone is half the problem.

2. Dependencies Explosion

Bad pattern:

dockerfile
COPY . .
RUN npm install

This copies everything and installs dev dependencies.

Better:

dockerfile
COPY package*.json ./
RUN npm install --production

Even better:

dockerfile
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:

dockerfile
RUN apt-get update
RUN apt-get install -y build-essential

Now 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:

dockerfile
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:

dockerfile
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

Good:

dockerfile
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:

  • .git
  • node_modules
  • logs
  • 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:

bash
docker run -it your-image sh

Inside the container:

bash
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):

dockerfile
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 .dockerignore configured?
  • 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:

dockerfile
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:

dockerfile
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.

Related Topics

Docker
Docker Image Optimization
Multi-Stage Builds
Dockerfile
Container Best Practices
DevOps
CI/CD

Read Next