How to Reduce Docker Image Size: Multi-Stage Builds Guide (2026)
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
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
How to Reduce AWS Lambda Cold Starts: Init Duration to SnapStart (2026)
Step-by-step guide to measuring and reducing AWS Lambda cold starts in 2026: read Init Duration, price the August 2025 INIT billing change, pick runtime and arm64, bundle and lazy-load, tune memory, enable SnapStart and provisioned concurrency, and fix common errors.
14 minOct 20, 2026Docker in 2026: Engine 29, containerd 2.3 LTS, Podman 6 and the AI Push
The state of Docker and containers in October 2026: Docker Engine 29.9, Desktop 4.96 and its licence terms, Model Runner and the MCP Toolkit, free Hardened Images, Hub pull limits, containerd 2.3 LTS, Podman 6.0, BuildKit 0.34 and OCI artifacts.
12 minOct 20, 2026How to Speed Up GitHub Actions CI: Caching, Sharding, Docker (2026)
A step-by-step guide to faster GitHub Actions in 2026: measure job timings, cancel stale runs, use setup-node, setup-python and setup-go caching, actions/cache v6, buildx type=gha layer caching, matrix test sharding and larger runners, plus a cache-miss troubleshooting table.
13 min