Big images slow down every build, push, pull and deployment, use more disk on every server, and usually contain more software for attackers to exploit. Most images can lose 50–90% of their size with a few changes.
First, measure
docker images myapp
docker history myapp:latest # size of each layer
dive shows exactly what each layer adds and highlights wasted space:
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive myapp:latest
1. Choose a smaller base image
| Base | Approx. size |
|---|---|
ubuntu:24.04 |
~80 MB |
debian:bookworm-slim |
~75 MB |
node:22 |
~1 GB |
node:22-slim |
~200 MB |
node:22-alpine |
~150 MB |
python:3.12 |
~1 GB |
python:3.12-slim |
~150 MB |
gcr.io/distroless/static |
~2 MB |
The full language images include compilers and build tools. -slim variants drop them. Alpine is smaller still but uses musl libc, which can break prebuilt binaries or Python wheels; test carefully.
2. Use multi-stage builds
Build with the heavy image, ship with a light one:
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]
This is the single biggest win for compiled languages and front-end builds. See multi-stage builds.
3. Clean up in the same RUN instruction
Each RUN creates a layer. Deleting files in a later layer doesn’t shrink earlier ones:
# Bad: the apt lists stay in the first layer
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Good
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
4. Skip recommended packages
--no-install-recommends (apt) and --setopt=install_weak_deps=False (dnf) avoid pulling in dozens of optional packages.
5. Don’t keep package manager caches
RUN pip install --no-cache-dir -r requirements.txt
RUN npm ci --omit=dev && npm cache clean --force
RUN apk add --no-cache curl
Or use BuildKit cache mounts, which keep caches outside the image — see Docker build cache.
6. Production dependencies only
npm ci --omit=dev, composer install --no-dev, separate requirements-dev.txt. Test frameworks, linters and TypeScript don’t belong in runtime images.
7. Use .dockerignore
Keep .git, local node_modules, logs, test data and build artifacts out of the build context — they can end up in the image via COPY . ..
8. Copy only what the app needs
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
instead of copying the whole build directory with sources and dev dependencies.
9. Strip binaries and debug info
Go: -ldflags="-s -w". Rust: strip = true in the release profile. C/C++: strip the binaries.
10. Avoid unnecessary tools
Shells, curl, editors and package managers in production images are convenient for debugging but add size and attack surface. Distroless images have none of them — use docker debug or ephemeral debug containers when you need tools. See Alpine vs Debian base images.
What not to do
- Don’t squash everything into one giant
RUNat the cost of losing all caching — balance size and build speed. - Don’t use
docker export | importtricks to flatten images; you lose metadata and history. - Don’t chase the last few megabytes if it makes the Dockerfile unreadable.
Typical results
| App | Before | After |
|---|---|---|
| Node.js API (full image → slim + prod deps) | ~1.2 GB | ~180 MB |
| Python Django (full → slim, wheels) | ~1.1 GB | ~200 MB |
| Go service (golang → distroless) | ~900 MB | ~15 MB |
| React SPA (node → nginx:alpine) | ~1 GB | ~45 MB |
Smaller images pull faster on every deployment — especially noticeable when your registry and servers are in different regions. Related: Dockerfile tutorial and Alpine vs Debian images.