You push a deploy at 4:40 on a Friday. The build finishes, the registry starts uploading, and you watch the progress bar crawl: 1.24 GB. Your entire application — the part you actually wrote — is about four megabytes of Python. Everything else is scaffolding you never asked for and will never run.

Nobody sets out to build a gigabyte container. It happens the way a garage fills up: one reasonable decision at a time. You started from the official base image because that's what the docs said. You installed a compiler because a dependency wouldn't build without it. You copied the whole project directory because listing files felt fussy. Each choice was fine. Stacked together, they produced an artifact that costs you money on every pull, minutes on every deploy, and a much larger surface for a security scanner to complain about.

The good news is that this is one of the most reliably fixable problems in software. There is no clever algorithm involved. It's four changes, none of which take more than an afternoon, and the typical result is an image somewhere between 80 MB and 200 MB — often a 10x reduction with no change to your application code at all.

Where the 1.2 GB actually comes from

Before cutting, look at what you're carrying. Docker will tell you, layer by layer:

docker history myapp:latest --no-trunc --format "table {{.Size}}\t{{.CreatedBy}}"

Run that on a typical unoptimized Python or Node image and the picture is almost always the same. The base image itself accounts for the largest single chunk. A package-manager step (apt-get install build-essential, or the toolchain that pip pulled in to compile a wheel) accounts for the next. Then there's a surprisingly fat layer from a single COPY . . that swept up your .git directory, your node_modules from local development, a venv/, and the 40 MB of sample CSVs you were testing against in March.

Here's the part that catches people: deleting files in a later layer does not shrink the image. Each instruction in a Dockerfile creates a layer, and layers are additive. If you install a 300 MB toolchain in one RUN and remove it in the next, both layers ship. The removal only hides the files from the filesystem view — the bytes are still in the image, still downloaded on every pull.

An image is not what you see when you look inside the container. It's every byte you ever wrote on the way there.

That single fact explains why the && rm -rf /var/lib/apt/lists/* idiom is always glued onto the same RUN line as the apt-get install. It's not style. Split them across two instructions and you have saved nothing.

Change one word in your base image

This is the highest ratio of benefit to effort in the entire exercise. Compare the official Python variants:

Base imageApprox. uncompressed sizeWhat you give up
python:3.12~1.0 GBNothing — full Debian, compilers, git, curl
python:3.12-slim~150 MBBuild tools, docs, extra locales
python:3.12-alpine~55 MBglibc — Alpine uses musl instead

Sizes drift between releases, so check the current ones with docker pull before you quote a number to anyone. The ranking, though, has been stable for years.

For the overwhelming majority of projects, -slim is the right answer, and it's a one-word change. You keep glibc, which means pre-built binary wheels for NumPy, Pillow, psycopg, cryptography and friends install instantly and correctly. You drop about 850 MB of things you were never going to invoke.

Alpine looks more tempting on the number alone, and for a statically compiled Go binary it's excellent. For Python it can backfire. Because Alpine uses musl rather than glibc, many Python packages have no prebuilt wheel for it, so pip falls back to compiling from source. Your build time goes from 30 seconds to eight minutes, you have to install gcc and musl-dev to make it work, and the resulting image sometimes ends up larger than the slim one you were trying to beat. The same trap exists in the Node ecosystem for packages with native bindings.

The rule of thumb: slim by default, Alpine only when you've measured it and the dependencies cooperate.

Stop shipping your build tools

The second big win is multi-stage builds, and the idea behind them is almost embarrassingly simple: the things you need to build software are not the things you need to run it. A compiler, a package manager, header files, a test suite — all essential at build time, all dead weight in production.

A multi-stage Dockerfile builds in one image and then copies only the finished artifacts into a fresh, clean one. Everything left behind in the first stage is discarded entirely.

# ---- Stage 1: build ----
FROM python:3.12-slim AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ---- Stage 2: runtime ----
FROM python:3.12-slim

# only the installed packages come across, not the compiler
COPY --from=builder /install /usr/local

WORKDIR /app
COPY app/ ./app/

RUN useradd --create-home --uid 10001 appuser
USER appuser

EXPOSE 8000
CMD ["python", "-m", "app.main"]

The build-essential and libpq-dev packages exist only in the builder stage. They compile whatever needs compiling, and then the final FROM starts over from a clean slim image. Only /install — the resulting site-packages — crosses the boundary.

The gains here are well documented and consistently large. Published comparisons of the same application typically show something in the range of an 850 MB single-stage image dropping to roughly 220 MB with multi-stage, and further still with a minimal runtime base. Your mileage depends entirely on how heavy your build dependencies are; projects that compile native extensions see the most dramatic cuts.

The Go version of this pattern is even starker, because a compiled Go binary needs essentially nothing around it:

FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /bin/server /server
USER nonroot
ENTRYPOINT ["/server"]

That final image is roughly the size of your binary plus a couple of megabytes. A 900 MB Go toolchain image becomes something you can pull in under a second.

The .dockerignore file nobody writes

Here's a five-minute fix that people skip for years. When you write COPY . ., Docker sends your entire build context to the daemon first — every file in the directory, including ones you'd never dream of shipping.

AD

Look in a typical project and you'll find .git (often hundreds of megabytes of history), a local node_modules or venv, __pycache__ directories, .env files with real credentials, test fixtures, coverage reports, and editor config. Some of that inflates the image. The credentials part is worse than inflation — a .env file baked into an image is a secret you have now published to your registry, where anyone who can pull the image can read it.

Create .dockerignore in your project root:

.git
.gitignore
.env
.env.*
**/__pycache__
**/*.pyc
.venv
venv/
node_modules
.pytest_cache
.coverage
htmlcov/
dist/
build/
*.log
tests/
docs/
README.md
Dockerfile
.dockerignore

Two effects, immediately. The build context shrinks, so docker build stops spending twenty seconds "sending build context to Docker daemon" before it does anything. And your image stops containing files you didn't intend to distribute.

The cheapest byte to optimize is the one you never copy in.

Layer order decides your rebuild time

Size is one cost; build time is the other, and it's governed by a different rule. Docker caches each layer and reuses it as long as that layer's inputs haven't changed. The moment one layer is invalidated, every layer after it rebuilds too.

Which means the order of your instructions is a performance decision. Consider the common mistake:

# slow: every code change re-installs all dependencies
COPY . .
RUN pip install -r requirements.txt

Change one line in one Python file, and COPY . . produces a different layer, which invalidates the cache for the pip install beneath it. You now reinstall every dependency, from the network, for a one-character typo fix.

Flip it:

# fast: dependencies cached until requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

Now the dependency layer is keyed only to requirements.txt. Edit your application all day and that expensive step stays cached. The general principle: order instructions from least frequently changed to most frequently changed. Base image, then system packages, then language dependencies, then your source code last.

Two smaller habits belong here. --no-cache-dir on pip (or npm ci --omit=dev, or go mod download before COPY . .) prevents the package manager from writing its own cache into the layer — a few hundred megabytes of nothing useful. And if you're on BuildKit, cache mounts let you keep that cache outside the image while still reusing it across builds:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Fast rebuilds, and none of the cache ends up in the shipped layer.

Distroless, and when to leave it alone

Once you're on slim plus multi-stage, the last step down is a distroless base — an image containing your application, its runtime dependencies, and nothing else. No shell. No package manager. No ls, no curl, no busybox.

The security argument is genuinely strong. Most container CVEs live in the userland utilities you never call, and an attacker who achieves code execution in a container with no shell has far fewer options. Combined with a :nonroot tag, you get a container that runs as an unprivileged user by default.

The catch is real too, and worth stating plainly: you cannot docker exec into a distroless container to look around. There's no shell to exec into. If your current debugging workflow is "shell into the pod and poke at it," distroless will take that away, and you need structured logging, metrics and readiness probes good enough that you never needed the shell. Teams that adopt distroless before they have that observability tend to quietly revert after the first incident.

A sensible progression: get to slim, then multi-stage, then run in production for a while, then consider distroless once your logs would actually tell you what happened without an interactive session.

A checklist you can run this afternoon

Work top to bottom and stop when the number is good enough:

  1. Measure first. docker images myapp for the total, docker history for the breakdown. Write the number down so you can prove the improvement.
  2. Write a .dockerignore. Five minutes, zero risk, immediate effect on build context and on accidental secret leakage.
  3. Switch to a -slim base. One word. Rebuild and check that everything still installs.
  4. Reorder for cache. Dependency manifests copied and installed before application source.
  5. Split into build and runtime stages. Compilers and dev headers live only in the builder.
  6. Clean up inside the same RUN. apt-get install ... && rm -rf /var/lib/apt/lists/*, --no-cache-dir for pip, --omit=dev for npm.
  7. Add a non-root user. Not a size win, but you're already editing the file, and it's a genuine security improvement.
  8. Measure again, and only then decide whether Alpine or distroless is worth the added constraints.

The reason this work is satisfying is that the feedback is instant and numerical. There's no debate about whether the refactor was worth it — you had 1.24 GB, and now you have 140 MB, and every deploy for the next two years is faster because of one afternoon.

Start with the .dockerignore. It's the smallest step, it costs nothing, and it's the one that most often turns up something you're glad you found.