Why a Docker image ends up ten times bigger than the app

A Node service with a few hundred kilobytes of source code ships as an image over 1 GB. That is the normal outcome of a working build, not a mistake anyone made on a particular line. The weight comes from a base image carrying a full OS, a build toolchain the running app never calls, and a package manager cache nobody told the build to delete.

You pay for it on every deploy: docker pull gets slower, and a vulnerability scanner has several hundred extra packages to report on. Storage adds up too, across every tag you have ever pushed. Fixing it means rewriting the Dockerfile, not the app.

Which base image to pick: alpine, slim, or distroless

The FROM line sets the floor. Pick badly there and nothing else in the file makes up the difference.

Base imageApproximate sizeWhat’s in it
node:261.77 GBFull Debian, compilers, multiple language runtimes, docs
node:26-slim371 MBTrimmed Debian, no build toolchain
node:26-alpine247 MBmusl libc, apk, roughly 50 packages
gcr.io/distroless/nodejs22-debian12212 MBNode runtime only, no shell, no package manager, roughly 10 packages

The pattern holds outside Node. debian:bookworm-slim runs about 74 MB against ubuntu:22.04’s 77 MB. alpine:latest is around 7 MB. gcr.io/distroless/static-debian12, built for a statically linked Go or Rust binary that needs nothing but CA certificates, is close to 2 MB.

Alpine gets its size by swapping glibc for musl libc, and that swap is the bill: native modules compiled against glibc (some npm packages, most pip packages with C extensions) segfault or throw missing-symbol errors under musl. A -slim tag avoids the risk and still cuts the image by two-thirds. Move to Alpine once you have confirmed your dependencies don’t care.

Distroless goes further. No shell, no package manager, nothing an attacker can run after getting code execution — and no sh for you either, which becomes a real cost the first time a container misbehaves in production.

Layer ordering, .dockerignore and the mechanics of a multi-stage build belong to Dockerfile best practices. Everything here assumes that split already exists and picks up from what a build stage alone doesn’t fix.

How to keep package manager caches out of the image

A multi-stage build keeps the compiler out of the runtime stage. It does nothing about the package manager’s own cache, which lands in whichever layer ran the install: harmless in a build stage you throw away, dead weight in the final stage if that stage installs anything itself.

BuildKit cache mounts handle it with no cleanup step:

1
2
3
4
5
6
7
8
9
# syntax=docker/dockerfile:1
FROM node:26-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

--mount=type=cache hands that RUN a directory that persists between builds and is never committed to a layer. npm’s cache still makes the next build faster; none of it reaches the image. Python gets RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt, Go --mount=type=cache,target=/root/.cache/go-build.

Without BuildKit, tell the installer not to cache at all: pip install --no-cache-dir, or npm ci && npm cache clean --force. Both halves go in the same RUN. Deleting a file in a later layer hides it behind a whiteout marker and leaves the bytes sitting in the layer below, so the split version ships the cache anyway. A service built by Docker Compose with build: . runs the same Dockerfile through the same builder, so cache mounts apply there unchanged.

How to strip debug symbols and files the app never reads

Build dependencies are half the problem. Runtime dependencies ship files that never get read in production: test suites, .md docs, compiled .pyc caches, source maps.

For Python, skip the pip cache and the bytecode cache in the layer that installs:

1
2
3
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-compile -r requirements.txt \
    && find /usr/local/lib -name '__pycache__' -exec rm -rf {} +

A compiled binary carries debug information production never reads. One flag in the build stage drops it:

1
2
# Go: strip the symbol table and DWARF debug info at build time
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
1
2
# C/C++/Rust: strip an already-built binary
RUN strip /app/binary

-ldflags="-s -w" commonly takes 20-30% off a Go binary. Nothing you would notice on a small CLI, real bytes on anything with a large dependency graph. Stripped symbols cost you only if you attach a debugger to the exact binary running in production, which is not something you do on a container that has no shell to attach from.

How to find which layer is making the image big

Docker recorded where the bytes went. Read it back:

1
docker history myapp:latest

One line per layer, with the instruction that created it and its size. Add --no-trunc when several RUN lines look alike in the truncated view.

That names the expensive instruction, not the expensive files inside it. dive does the second half:

1
dive myapp:latest

It opens a terminal UI over the layers, color-coding added, modified and deleted files, and reports an efficiency score alongside total wasted bytes: space held by files a later layer overwrote or deleted but never removed from the image. CI=true dive myapp:latest runs the same check non-interactively and exits non-zero when the image misses the thresholds in a .dive-ci file:

1
2
3
4
rules:
  lowestEfficiency: 0.95
  highestWastedBytes: 20MB
  highestUserWastedPercent: 0.10

An image that quietly grew stops being something a colleague notices weeks later and becomes a failed check on the pull request that caused it.

When shrinking the image isn’t worth the effort

A script you build once and run on your own machine needs no distroless base and no stripped binary. The image never leaves your disk, and the work costs more than the gigabyte it saves. Same for a short-lived CI job image rebuilt from scratch every run: 200 MB off something pulled once and discarded buys nothing.

The tradeoff bites at the small end. A distroless or scratch image has no shell, so docker exec -it myapp sh on a misbehaving container fails outright. You debug from logs and a local reproduction, or you keep a debug variant around for exactly that (distroless publishes -debug tags with a busybox shell). Alpine gives up less of that convenience and hands you the musl risk instead. None of these costs are hidden, and they are why “use the smallest base image everywhere” is worse advice than choosing the base per service, from what that service’s dependencies need.

Where to start on an image you already have

Run docker history on what you ship today, before changing anything. The biggest layer is usually an obvious fix once it has a name next to it. Then run dive against the same image with the thresholds above: a run that fails on the first try gives you a number to beat and a file-level report of where the waste sits, which beats guessing at a Dockerfile from what usually bloats images.