A 2GB image for a service whose code is a few megabytes is not a mystery. It is four predictable mistakes stacked on top of each other, and every one of them is visible if you look at the layers.
The question “why is my Docker image so large” almost never has an exotic answer. In practice the weight comes from a full operating-system base image, package-manager caches left behind inside a layer, the entire build context copied in wholesale, and a missing multi-stage split that ships the compiler alongside the application. You do not need to guess which of these bit you. Two commands will tell you exactly where the bytes went before you change a single line of the Dockerfile.
Find the weight before you touch anything
Start with docker history. It lists every layer, largest-contributing instruction first, and it is already installed. The trick is to stop it truncating the command that created each layer, because the command is what tells you what happened.
Free · 4 minutes
Do you actually know what you are running — and what it is about to cost you?
Fourteen questions on the systems you depend on, the ones nobody owns, and the support dates that turn a routine upgrade into a forced re-platform. Banded finding on screen, full sheet by email.
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' myapp:latest
Read the output top to bottom. A single 900MB layer against a FROM python:3.12 base is your operating system. A 300MB layer whose command contains apt-get install with no cleanup in the same instruction is a cache you are shipping to production. A fat layer created by COPY . . is your build context — very likely including a .git directory, local virtual environments and test fixtures that have no business in a runtime image.
For the detail docker history cannot give you, use dive. It opens each layer as a filesystem tree, shows precisely which files an instruction added, and computes an efficiency score plus a total of wasted bytes — space consumed by files that a later layer duplicated, moved or deleted but which remain in the image because layers are additive.
# interactive: arrow through layers, see what each one added
dive myapp:latest
# non-interactive pass/fail for a pipeline
CI=true dive myapp:latest
The wasted-bytes figure is the one to fixate on. A high number means files are being written in one layer and removed in a later one, which never reclaims space — the classic being an apt cache or a downloaded archive deleted in a separate RUN.
The four causes, and the fix for each
The fat base image. python:3.12, node:22 and their kin ship a complete Debian userland — compilers, locales, documentation, tooling you will never call at runtime. Switching to the -slim variant typically removes several hundred megabytes on its own. For a compiled or dependency-frozen application, a distroless base such as gcr.io/distroless/python3 goes further, stripping the shell and package manager entirely, which also shrinks the attack surface.
The cache left in a layer. Because each RUN is its own layer, you must install and clean up in the same instruction. Deleting the cache in a subsequent RUN leaves the bytes sitting in the earlier layer.
# wrong: the cache is baked into the layer above
RUN apt-get update && apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/*
# right: install and clean in one layer, skip recommended extras
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
The build context copied in wholesale. COPY . . takes everything the build daemon received, and without a .dockerignore that means your repository history and local junk. A .dockerignore at the build root fixes it:
.git
node_modules
.venv
__pycache__
*.pyc
tests/
*.md
Dockerfile
The missing multi-stage split. If you need build-essential to compile a dependency, you need it at build time, not at run time. A multi-stage build compiles in one stage and copies only the finished artefacts into a clean final image, so the toolchain never reaches production.
# ---- build stage ----
FROM python:3.12-slim AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ---- runtime stage ----
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /install /usr/local
COPY . .
CMD ["python", "app.py"]
Note pip install --no-cache-dir: pip keeps its own download cache, and that is another few hundred megabytes you do not want in the layer. The runtime stage never sees build-essential, so it never carries its weight.
Measure the before and after, and hold the line
Rebuild and compare honestly with docker images. In a typical Python service, moving from a full base to -slim, adding a .dockerignore, cleaning the apt cache in place and splitting into two stages takes an image from roughly 2GB to a couple of hundred megabytes. The exact figure depends on your dependencies, but the direction is never in doubt.
The reason this is worth doing is not aesthetic. Smaller images pull faster, which shortens deployment time and cold-start latency directly — it is one of the cheaper wins available if you are instrumenting lead time and deploy frequency and looking for slack in the pipeline. A slimmer base also means fewer packages, which means fewer CVEs for your scanner to flag.
The bloat will creep back the moment nobody is watching, so make the check automatic rather than heroic. dive reads a .dive-ci file at the repository root with three rules — lowestEfficiency, highestWastedBytes and highestUserWastedPercent — and fails the build when an image drifts past them. Wire CI=true dive into the pipeline the same way you would wire any other gate, alongside the pre-commit and CI enforcement baseline that catches the rest of your quality regressions.
A 2GB image is not bad luck. It is four defaults nobody overrode, and every one of them is a two-line fix once you have looked at the layers.
Build and rescue work
Hands-on delivery of this kind is handled by Sixteen Pillars Studio.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.