How to Reduce Docker Image Size: Multi-Stage Builds Guide (2026)

Oct 20, 2026 · 12 min · Ajay Kumar

This guide is for anyone whose CI pushes a 1 GB image to deploy a 10 MB service. By the end you will have multi-stage Dockerfiles for Go, Node.js, Python and Java, a .dockerignore, BuildKit cache mounts that keep rebuilds fast, and a way to see exactly which layer is bloating an image. I built every before/after example below on 20 October 2026 with Docker Engine 28.0.4 (Docker Desktop, containerd image store, buildx v0.22) targeting linux/amd64, using golang:1.27, node:24, python:3.13 and eclipse-temurin:25 base images, and I smoke-tested each optimised image with curl. You need Docker with BuildKit (the default builder since Engine 23) and a shell; nothing else.

Step 1: Measure the image before you change anything

Image size has two numbers and people mix them up. The compressed size is what the registry stores and what every node downloads on a cold pull. The unpacked size is what sits on disk after extraction. With the containerd image store (the default for new installs since Docker Engine 29), docker images on my machine reports the compressed content size. To get the unpacked filesystem size of any image, including ones with no shell, export a stopped container:

# Compressed (registry) size as Docker reports it
docker image inspect --format '{{.Size}}' myapp:naive

# Unpacked filesystem size in bytes, works for distroless/scratch too
c=$(docker create myapp:naive) && docker export "$c" | wc -c && docker rm "$c" >/dev/null

# Which instruction added what
docker history --format 'table {{.Size}}\t{{.CreatedBy}}' myapp:naive

Here is docker history on my naive Python image. The pip install layer is 19.5 MB; the rest is the full Debian-based python:3.13 image with compilers and headers you will never use at runtime:

SIZE      CREATED BY   (trimmed to the non-zero layers)
0B        CMD ["gunicorn" "-b" "0.0.0.0:8000" "app:app…
19.5MB    RUN /bin/sh -c pip install -r requirements.t…
28.7kB    COPY . . # buildkit
75.9MB    RUN /bin/sh -c set -eux;   wget -O python.ta…
19.9MB    RUN /bin/sh -c set -eux;  apt-get update;  a…

For an interactive view, dive (v0.13.1) shows each layer's file tree and an "efficiency" score that flags files written in one layer and deleted in a later one. Its README documents running it as a container:

docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
  docker.io/wagoodman/dive:v0.13.1 myapp:naive

Write both numbers down. Every step below should move them.

Step 2: Add a .dockerignore so the build context stays small

Everything in the build directory is sent to BuildKit, and a COPY . . copies whatever arrived. Without a .dockerignore, that means your local node_modules, .git, test fixtures and .env files. Docker's cache optimisation guide recommends excluding them so they never reach the builder or the cache. Start with this and add your own build outputs:

# .dockerignore
.git
.github
node_modules
**/__pycache__
*.pyc
.venv
target/
dist/
coverage/
*.log
.env
.env.*
Dockerfile*
docker-compose*.yml

Two reasons beyond size. A local node_modules built for macOS will be copied over the Linux one and break native modules. And .env files in the context end up in an image layer that anyone with pull access can read, which is how a lot of the leaks in the secrets sprawl post start.

Step 3: Order layers so dependencies cache separately from code

BuildKit reuses a layer only if that instruction and everything before it is unchanged. Copy the dependency manifest first, install, then copy source. A code edit then reuses the dependency layer instead of reinstalling the world. The Docker docs put it plainly: steps that change often go near the end.

# Bad: any source change invalidates npm install
COPY . .
RUN npm install

# Good: npm ci only reruns when the lockfile changes
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .

On Debian-based images, keep apt-get update and apt-get install in one RUN, add --no-install-recommends, and delete /var/lib/apt/lists/* in the same instruction, as the build best practices page recommends. Deleting files in a later RUN does not shrink the image, because the earlier layer still contains them.

Step 4: Use a multi-stage build (Go example)

A multi-stage build compiles in a fat image and copies only the artifact into a small one. The build stage never ships. This is the single biggest win, and Go shows it best because the output is one static binary.

# syntax=docker/dockerfile:1
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
# CGO_ENABLED=0 gives a static binary; -s -w strips symbol and DWARF tables
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Results for a small net/http server:

Variant Compressed Unpacked
Single stage on golang:1.27 343 MB 1,005 MB
Multi-stage, alpine:3.24 runtime 6.4 MB 14.5 MB
Multi-stage, distroless/static-debian13:nonroot 3.4 MB 9.2 MB

Google's distroless README puts static-debian13 at about 2 MiB; the rest is the binary. FROM scratch would be slightly smaller, but you lose CA certificates, /etc/passwd with a non-root user, and timezone data, which distroless static includes. If your Go code uses cgo (SQLite, some DNS setups), use gcr.io/distroless/base-debian13 or cc-debian13 instead, which carry glibc.

Step 5: Pick a runtime base: slim, alpine or distroless

Choosing the runtime stage base image Compiles to a static binary? (Go, Rust) Native code needing glibc or C libraries? Interpreted runtime? (Node, Python, Java) Need a shell or apt inside the container? distroless/static or scratch (about 2 MiB base) distroless cc or base, or debian:13-slim language slim, distroless runtime, or jlink JRE slim image; distroless :debug tag only to debug yes yes yes yes no no also ask Compressed size, naive to optimised (20 Oct 2026, linux/amd64): Go 343 MB to 3.4 MB, Node 423 MB to 56 MB, Python 420 MB to 25 MB, Java 151 MB to 53 MB. Unpacked sizes are about 2.5 to 3 times larger.
Decision flow for the final stage of a multi-stage build. Sizes are from the builds in this guide; the distroless sizes come from Google's distroless images on Debian 13.

The trade-offs, briefly. Slim images (node:24-slim, python:3.13-slim) are Debian with most packages removed: glibc, a shell and apt, so almost everything works. Alpine is smaller but uses musl instead of glibc; prebuilt binaries and some Python wheels built for glibc will not load, and you can end up compiling from source, which makes the build stage bigger and slower. Distroless has no shell, no package manager, and a non-root user via the :nonroot tag, which cuts both size and the CVE scanner noise discussed in Container Image Security in 2026. Docker's own Hardened Images, free under Apache 2.0 since December 2025, are a comparable option.

Step 6: Node.js: install production dependencies only

Node images get big from two places: the full node:24 base (Debian with build tools) and devDependencies. Install with npm ci --omit=dev in one stage, copy node_modules into a slim or distroless runtime:

# syntax=docker/dockerfile:1
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

FROM gcr.io/distroless/nodejs24-debian13:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY server.js package.json ./
# the distroless nodejs entrypoint is already "node"
CMD ["server.js"]

If you have a TypeScript or bundler step, add a third stage that runs npm ci with devDependencies and npm run build, then copy only dist/ into the runtime. For an Express app:

Variant Compressed Unpacked
node:24, npm install, single stage 423 MB 1,190 MB
Multi-stage, node:24-slim runtime 82 MB 235 MB
Multi-stage, distroless/nodejs24-debian13 56 MB 159 MB

Step 7: Python: build in one stage, copy the packages

The pattern is the same: install into a directory in a build stage, copy that directory. A virtualenv works well with a slim runtime of the same Python version:

# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
RUN python -m venv /venv
ENV PATH=/venv/bin:$PATH
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

FROM python:3.13-slim
COPY --from=build /venv /venv
ENV PATH=/venv/bin:$PATH PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY app.py .
USER nobody
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]

For distroless, a venv's python symlink points at /usr/local/bin/python, which does not exist there (distroless ships Debian's /usr/bin/python3). Install with --target and set PYTHONPATH instead. python3-debian13 ships Python 3.13, so the build stage must be 3.13 too, or compiled wheels will not import:

# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
RUN pip install --no-cache-dir --target /deps flask==3.1.2 gunicorn==23.0.0

FROM gcr.io/distroless/python3-debian13:nonroot
COPY --from=build /deps /deps
COPY app.py /app/app.py
WORKDIR /app
ENV PYTHONPATH=/deps
ENTRYPOINT ["python3", "-m", "gunicorn", "-b", "0.0.0.0:8000", "app:app"]
Variant Compressed Unpacked
python:3.13, single stage 420 MB 1,145 MB
Multi-stage venv, python:3.13-slim 49 MB 137 MB
Multi-stage, distroless/python3-debian13 25 MB 68 MB

If you need packages with C extensions and no wheel exists, install build-essential in the build stage only. That is the whole point of having two stages.

Step 8: Java: ship a JRE, or build your own with jlink

A JDK in production carries the compiler, jshell and headers. Switching the runtime to the JRE image helps a little; jlink helps a lot, because it builds a runtime containing only the modules your app uses. Find them with jdeps --print-module-deps app.jar.

# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY Main.java .
RUN javac -d /out Main.java
# java.base plus the JDK HTTP server module this app uses
RUN jlink --add-modules java.base,jdk.httpserver --strip-debug --no-man-pages \
    --no-header-files --compress=zip-6 --output /jre

FROM debian:13-slim
COPY --from=build /jre /opt/jre
COPY --from=build /out /app
USER 65532
ENTRYPOINT ["/opt/jre/bin/java", "-cp", "/app", "Main"]
Variant Compressed Unpacked
eclipse-temurin:25-jdk, single stage 151 MB 421 MB
Multi-stage, eclipse-temurin:25-jre 117 MB 342 MB
Multi-stage, jlink runtime on debian:13-slim 53 MB 125 MB

A real Spring Boot app needs more modules (java.sql, java.naming, jdk.unsupported and so on), so expect a larger jlink runtime than this toy server; run jdeps rather than guessing. Distroless java25-debian13 is the no-effort alternative.

Step 9: Keep it small in CI

Sizes creep back. Two cheap guards: run Docker's build checks, which lint the Dockerfile for common mistakes, and fail the pipeline if dive's wasted-space rules trip. dive reads rules from a .dive-ci file when CI=true:

# Lint the Dockerfile with BuildKit's built-in checks
docker build --check .
# Expected on a clean file: "Check complete, no warnings found."
# .dive-ci (keys from the dive README)
rules:
  lowestEfficiency: 0.95
  highestWastedBytes: 20MB
  highestUserWastedPercent: 0.10

Pin base images by digest once things work (FROM node:24-slim@sha256:...) and let Dependabot or Renovate bump them, as the best practices page suggests. If your CI pulls from Docker Hub, authenticate first; the limits and why they bite are in Docker in 2026.

Troubleshooting

Symptom Likely cause Fix
exec /app: no such file or directory on distroless static or scratch Binary is dynamically linked (cgo on) Build with CGO_ENABLED=0, or use distroless/base-debian13
ModuleNotFoundError in distroless Python Packages installed for another Python version, or PYTHONPATH unset Match build stage to 3.13 and set PYTHONPATH to the --target dir
Error loading shared library on Alpine glibc binary or wheel on musl Use a -slim (glibc) image, or install the musl build
Image did not shrink after rm -rf Files deleted in a later layer still exist in the earlier one Delete in the same RUN, or move the work to a build stage
Every build reinstalls dependencies COPY . . before install, or no .dockerignore Copy lockfile first; ignore node_modules, .git
--mount=type=cache ignored or syntax error Legacy builder or old frontend Use BuildKit (default) and keep # syntax=docker/dockerfile:1
Cannot docker exec ... sh into the container Distroless has no shell Use the :debug tag temporarily, or docker debug in Desktop
docker images size differs from the registry UI Compressed versus unpacked sizes Compare like with like; use docker export for unpacked
dive cannot load the image Image store or API mismatch docker save -o app.tar myapp then dive docker-archive://app.tar

FAQ

What is the smallest Docker base image?

scratch is empty, so it is the smallest possible. In practice gcr.io/distroless/static-debian13, about 2 MiB, is the better choice for static binaries because it adds CA certificates, a non-root user and timezone data. For interpreted languages, a distroless runtime image is usually smallest.

Is Alpine better than distroless for small images?

Not usually. Alpine is small, but it uses musl, which breaks some glibc binaries and Python wheels and gives you a shell and package manager you may not want in production. In my Go test, distroless static came out at 3.4 MB compressed versus 6.4 MB on Alpine.

Do multi-stage builds slow down builds?

No, if you order layers and use cache mounts. Stages that do not depend on each other build in parallel under BuildKit, and only the final stage is exported. Cache mounts keep package downloads across builds without adding them to any layer.

Does image size actually matter?

It changes cold-start pull time on every new node, registry storage and egress costs, and how many packages a scanner reports. It matters most for autoscaling, where nodes pull images while users wait, and in CI, where images are pulled on every run.

When an image is too big, I run docker history before I open the Dockerfile, because the answer is almost always one layer: a full SDK base, a copied node_modules, or a cache left in a RUN. Then I move the build to its own stage, switch the runtime to distroless or slim, and only then reach for micro-optimisations like stripping binaries. The official references are Docker's multi-stage build docs, cache optimisation guide, build best practices and the distroless README.

Related: Container vs microVM vs gVisor: Choosing Agent Isolation, CRA Reporting Went Live on 11 September: 24 Hours to ENISA, 3.08 Billion Rekor Entries, 17% of PyPI Attested, 92% SBOM False Positives and Container Image Security in 2026.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related