Multi-stage builds are easy to explain with Go, where the final stage holds one static binary. Python and Node.js are harder: the runtime needs an interpreter, a dependency tree, and sometimes native libraries, so you cannot just copy one file into scratch. This guide builds a real FastAPI service and a real Express service three ways, measures each image, and shows the stage layout that actually removes the weight.

If you are building Go or another language that compiles to a static binary, Multi-Stage Docker Builds: Reducing Image Sizes by 800MB covers the scratch and distroless patterns for that case, and How to Reduce Docker Image Size is the short checklist of general techniques. This post is about interpreted runtimes and about measuring.

All numbers below come from Docker Engine 29.8 with BuildKit on linux/amd64. Your exact sizes will differ with base image updates, but the ratios hold.

Measure first: where the bytes actually are

Before changing a Dockerfile, find out what is in the image. Three commands answer most questions.

docker image ls gives two sizes on Docker Engine 29 with the containerd image store: DISK USAGE (unpacked on disk) and CONTENT SIZE (compressed, which is roughly what a node pulls). Older engines show a single SIZE column, which is the unpacked size.

bash
docker image ls demo-py
# IMAGE           ID             DISK USAGE   CONTENT SIZE
# demo-py:multi   c8f480cd133d        223MB         53.8MB
# demo-py:naive   9449f45eb8c1        1.7GB          448MB

docker history shows the size each instruction added. This is where you see that a cleanup step does nothing:

bash
docker history demo-py:naive --format '{{.Size}}\t{{.CreatedBy}}' | head -6
# 0B      CMD ["uvicorn" "app.main:app" "--host" "0.0.0.0" "--port" "8000"]
# 1.32MB  RUN /bin/sh -c apt-get purge -y build-essential && rm -rf /var/lib/apt/lists/*
# 37.5MB  RUN /bin/sh -c pip install -r requirements.txt
# 22.8MB  RUN /bin/sh -c apt-get update && apt-get install -y build-essential libpq-dev
# 20.5kB  COPY . . # buildkit
# 8.19kB  WORKDIR /app

The purge layer adds 1.32 MB. Layers are append only: removing a file in a later layer writes a whiteout entry, and the bytes stay in the earlier layer that every node still pulls.

dive walks the layers and reports files that were written and later overwritten or deleted. Run it as a container so you do not have to install it, and set CI=true to get a report instead of the interactive UI:

bash
docker run --rm -e CI=true \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive:v0.13.1 demo-py:naive
#   efficiency: 97.1952 %
#   wastedBytes: 34508349 bytes (34 MB)
# Inefficient Files:
#     2   21 MB  /var/lib/apt/lists/deb.debian.org_debian_dists_trixie_main_binary-amd64_Packages.lz4

Notice what dive does not flag: the naive image is 1.7 GB, and dive calls it 97% efficient. Its “wasted bytes” metric only counts files that were shadowed or deleted by a later layer. A compiler toolchain you installed and never removed is not “wasted” by that definition, it is just shipped. dive is good at catching cleanup done in the wrong layer; it will not tell you that your base image is the wrong one. docker history plus the base image size will.

In CI you can gate on dive’s thresholds with --ci and --lowestEfficiency, --highestWastedBytes or --highestUserWastedPercent, but a plain size budget is usually the more useful check:

bash
size=$(docker image inspect demo-py:multi --format '{{.Size}}')
[ "$size" -lt 300000000 ] || { echo "image is $size bytes, budget is 300 MB"; exit 1; }

Python: build a virtualenv, copy the virtualenv

The demo is a FastAPI app that uses psycopg2. That package is a good test because installing it from source needs a C compiler and libpq-dev, while running it only needs the libpq5 shared library.

textrequirements.txt
fastapi==0.115.12
uvicorn==0.34.2
psycopg2==2.9.10

The naive version: 1.7 GB

dockerfileDockerfile.naive
FROM python:3.13
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y build-essential libpq-dev
RUN pip install -r requirements.txt
RUN apt-get purge -y build-essential && rm -rf /var/lib/apt/lists/*
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Two problems. The python:3.13 base is the full Debian image with compilers and development headers already in it, which is most of the 1.7 GB. And the purge in its own RUN is useless, as docker history showed.

The multi-stage version: 223 MB

The trick for Python is to install everything into a virtualenv at a fixed path in the build stage, then copy that one directory into a runtime stage built from the same base image. A virtualenv contains absolute paths to its interpreter, so the Python version and its location (/usr/local/bin/python3 in the official images) must match between stages.

dockerfileDockerfile
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean && \
    apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev libpq-dev
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM python:3.13-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=build /opt/venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
WORKDIR /app
COPY app/ ./app/
USER 65532:65532
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

What each piece buys you:

  • python:3.13-slim in both stages. The slim image has the interpreter and nothing else. The build stage adds gcc, libc6-dev and libpq-dev; none of them reach the runtime stage.
  • Cache mounts for apt and pip. --mount=type=cache keeps the apt archives and the pip wheel cache between builds without writing them into a layer. Deleting docker-clean is required for the apt cache, because the Debian base images ship that file to throw downloaded packages away after every install. The pattern is from Docker’s build cache optimization guide.
  • Runtime libraries installed separately. The runtime stage gets libpq5 and cleans the apt lists in the same RUN, so the lists never land in a layer.
  • Code copied last. requirements.txt is copied and installed before the application code, so editing app/ does not invalidate the dependency layers.

Result, same app, same dependencies:

ImageDisk usageContent size (pulled)
demo-py:naive1.7 GB448 MB
demo-py:multi223 MB53.8 MB

Check that the native extension still links against the runtime library:

bash
docker run -d --rm --name demo-py -p 18000:8000 demo-py:multi
curl -s localhost:18000/healthz
# {"ok":true,"libpq":170011}

If the runtime stage is missing a shared library, this is where you find out: the import fails with ImportError: libpq.so.5: cannot open shared object file. Add the runtime package (not the -dev package) to the runtime stage.

Node.js: separate production dependencies from the build

The Node.js demo is an Express 5 server written in TypeScript. The build needs TypeScript, type definitions, ESLint and Vitest; the runtime needs Express and the compiled JavaScript.

The naive version: 1.8 GB

dockerfileDockerfile.naive
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]

The full node:22 image, every devDependency, the TypeScript sources, and whatever was in the build context (a local node_modules too, without a .dockerignore).

The multi-stage version: 210 MB on distroless

Use three stages: one that installs only production dependencies, one that installs everything and compiles, and a runtime stage that takes the output of each.

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

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["dist/server.js"]

Notes:

  • npm ci --omit=dev in its own stage. Installing production dependencies separately is cleaner than running npm prune after a full install, and BuildKit builds the deps and build stages in parallel because neither depends on the other.
  • /root/.npm as a cache mount. npm’s download cache stays on the builder between runs and never enters a layer.
  • Distroless runtime. gcr.io/distroless/nodejs22-debian12 contains Node.js, glibc, CA certificates and little else: no shell, no package manager. Its entrypoint is already the node binary, so CMD is just the script path. The :nonroot tag runs as UID 65532.
  • No shell means no docker exec -it ... sh. Debug with kubectl debug and an ephemeral container, or build a :debug variant of the image for local work. If your team relies on shelling into containers, use node:22-bookworm-slim as the runtime base instead. In this demo that costs 122 MB on disk (332 MB instead of 210 MB) and 27 MB more to pull.
ImageDisk usageContent size (pulled)
demo-node:naive1.8 GB449 MB
demo-node:multi210 MB53.3 MB
bash
docker run --rm --entrypoint sh demo-node:multi -c true
# exec: "sh": executable file not found in $PATH

That error is the point: there is nothing in the runtime image an attacker can use as a shell.

Native modules in Node.js

Packages such as bcrypt, sharp or better-sqlite3 ship prebuilt binaries for common platforms, but fall back to compiling with node-gyp when no prebuild matches. If that happens, install python3, make and g++ in the deps stage only. The compiled .node files end up inside node_modules, so the runtime stage needs nothing extra unless the module links to a system library. Base both stages on the same Debian release (bookworm here, which is what distroless/nodejs22-debian12 is built on) so the glibc version matches.

Keep build secrets out of every layer

Private registries need a token at install time. Do not pass it with ARG or COPY .npmrc: build arguments show up in docker history of the stage that uses them, and a copied file lives in a layer even if you delete it later. Mount it as a build secret, which exists only for the duration of that one RUN:

dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
bash
docker build --secret id=npmrc,src=$HOME/.npmrc -t demo-node:multi .

The same pattern works for pip with pip.conf or a netrc file. After the build, docker save demo-node:multi | strings | grep <token> finds nothing.

A .dockerignore that matters for interpreted languages

The build context is sent to BuildKit before anything runs. For Node.js the dangerous entry is a host node_modules, which can be hundreds of megabytes and contains binaries for the wrong OS; COPY . . would copy it straight over what npm ci installed.

text.dockerignore
.git
node_modules/
dist/
coverage/
__pycache__/
*.pyc
.venv/
.pytest_cache/
*.log
.env*
Dockerfile*
.dockerignore

Lint the Dockerfile in CI

Docker’s build checks (Dockerfile 1.8 and Buildx 0.15 or later) run a set of rules without building anything:

bash
docker build --check .
# Check complete, no warnings found.

Add # check=error=true under the # syntax= line to make warnings fail the build. See Docker Build Checks and Dockerfile Best Practices for the rule list.

FAQ

Why not use Alpine for Python?

Many Python wheels on PyPI are built for glibc (manylinux). On Alpine, which uses musl, pip often falls back to building from source, which needs the compilers you were trying to avoid and makes builds slower. musllinux wheels have closed some of that gap, but a slim Debian base is the predictable choice. For Node.js, Alpine works for pure JavaScript dependencies; native modules have the same musl caveat.

Can I use distroless for Python too?

Google publishes gcr.io/distroless/python3-debian12, but its interpreter is whatever Debian 12 ships, not the Python from the official python:3.13-slim image, so a virtualenv built in one will not run in the other. If you want distroless Python, build the venv in a debian:12 stage with Debian’s own python3 so the interpreter version and path match, and smoke test the result, because distroless has no shell to debug a broken venv with.

My image is still big after all this. What next?

Run docker history on the runtime stage and look at the largest layer. It is almost always one of: the base image itself, a dependency that pulls in a large native library (machine learning packages are the usual suspects), or a COPY that brought in more than intended. Docker Image Layer Caching: Optimization Strategies covers ordering instructions so the big layers are reused between builds.

Do multi-stage builds make builds slower?

No, usually the opposite. Independent stages build in parallel, and with cache mounts the dependency install step reuses downloads even when the lock file changes.