How To Guide

How to Reduce Docker Image Size: 10 Proven Tactics

Shrink Docker images 70-90% with multi-stage builds, distroless bases, layer merging, and BuildKit cache — real before/after numbers included.

How to Reduce Docker Image Size

A default node:20 image weighs around 1.1 GB. The same app on node:20-alpine drops to ~180 MB, and on gcr.io/distroless/nodejs20-debian12 it lands near 150 MB — an 86% reduction with zero code changes. Smaller images pull faster, cost less to store, and expose a smaller attack surface. Here are ten tactics that consistently move the needle.

1. Pick a small base image

The base image dominates final size. Reach for slim, alpine, or distroless variants before anything else:

  • python:3.12 — 1.02 GB
  • python:3.12-slim — 130 MB
  • python:3.12-alpine — 55 MB
  • gcr.io/distroless/python3-debian12 — ~50 MB

Alpine uses musl libc, which can break wheels for numpy, cryptography, or grpc — verify your dependencies compile before committing. Distroless images ship no shell and no package manager, which is a security win but means you must debug via ephemeral sidecars.

2. Use multi-stage builds

Compile in a fat builder stage, then copy only artifacts into a lean runtime stage. The build tools never ship.

FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app/dist /app
COPY --from=builder /app/node_modules /app/node_modules
CMD ["/app/server.js"]

A Go binary built this way often ships in a 15 MB scratch image versus a 900 MB golang:1.22 image — a 98% cut.

3. Combine RUN commands and clean in the same layer

Every RUN creates a new layer. Deleting files in a later layer does not shrink earlier ones. Chain installs and cleanup with && in a single instruction:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

The rm -rf /var/lib/apt/lists/* must live in the same RUN — otherwise the 40 MB apt cache is baked into the layer forever. For Alpine, use apk add --no-cache which skips the index automatically.

4. Use --no-install-recommends and language flags

Debian and Ubuntu pull in a long tail of recommended packages by default. --no-install-recommends typically saves 50-200 MB. Language ecosystems have equivalents:

  • Node: npm ci --omit=dev (or --production) skips devDependencies
  • Python: pip install --no-cache-dir -r requirements.txt avoids the ~/.cache/pip directory
  • Ruby: bundle config set without 'development test'
  • Go: CGO_ENABLED=0 go build -ldflags="-s -w" strips symbols

5. Write a strict .dockerignore

Anything in the build context is a candidate for accidental COPY .. A minimal .dockerignore for most projects:

.git
.gitignore
node_modules
**/__pycache__
**/*.pyc
tests/
docs/
.env*
Dockerfile
.dockerignore
coverage/
dist/
.vscode
.idea

Excluding .git alone can save hundreds of MB on mature repositories, and skipping local node_modules forces a clean install inside the container.

6. Enable BuildKit and inline cache

BuildKit is the default in Docker 23+, but its cache features are still opt-in. Two flags matter:

docker build \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --cache-from=myapp:latest \
  -t myapp:latest .

BUILDKIT_INLINE_CACHE=1 embeds cache metadata in the image itself so CI runners can pull the previous build and reuse layers — often turning multi-minute cold builds into much shorter incremental ones. Combine with RUN --mount=type=cache,target=/root/.npm to persist package caches across builds without adding them to the image.

7. Analyze layers with docker history and dive

Before optimizing, measure. docker history --no-trunc myapp:latest lists every layer with its size — the biggest offender is usually obvious. For deeper analysis, install dive:

dive myapp:latest

Dive shows which files each layer added and computes a wasted-space score. A score above 10% means files are being added and then deleted or overwritten in later layers — a signal to consolidate RUN steps or move the deletion into the same layer.

8. Copy dependency manifests first

Order matters for cache reuse. Copy package.json or requirements.txt before the rest of the source so dependency layers survive source code changes:

COPY package*.json ./
RUN npm ci
COPY . .

This does not shrink the final image but keeps rebuild times low, which reduces the temptation to skip clean rebuilds.

9. Squash or flatten only as a last resort

--squash collapses all layers into one, which can save space when many layers touch the same files, but destroys cache sharing across images. Prefer targeted layer merging over blanket squashing.

10. Verify with the final numbers

After each change, rebuild and compare with docker images. A realistic optimization path for a Node.js API:

  • Baseline (node:20, no ignore file): 1.4 GB
  • Add .dockerignore and npm ci --omit=dev: 950 MB
  • Switch to node:20-alpine: 220 MB
  • Multi-stage build to distroless: 140 MB

That is a 90% reduction achieved entirely through Dockerfile edits — no code changes and no loss of functionality.

docker image size multi-stage build distroless alpine linux dockerignore buildkit cache dive layer analysis docker optimization

Explore 300+ Free Tools

Utilko has tools for developers, writers, designers, students, and everyday users — all free, all browser-based.