Multi-Stage Docker Builds for Python and Node.js
Quick fix
Build dependencies in a stage that has compilers, then copy only the result into a slim runtime stage:COPY --from=build /opt/venv /opt/venv for a Python virtualenv, or COPY --from=deps /app/node_modules ./node_modules after npm ci --omit=dev for Node.js.
Measure before and after with docker image ls and docker history; deleting files in a later RUN never shrinks an image.On this page
- Measure first: where the bytes actually are
- Python: build a virtualenv, copy the virtualenv
- Node.js: separate production dependencies from the build
- Native modules in Node.js
- Keep build secrets out of every layer
- A
.dockerignorethat matters for interpreted languages - Lint the Dockerfile in CI
- FAQ
- Related guides
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.
docker image ls demo-py
# IMAGE ID DISK USAGE CONTENT SIZE
# demo-py:multi c8f480cd133d 223MB 53.8MB
# demo-py:naive 9449f45eb8c1 1.7GB 448MBdocker history shows the size each instruction added. This is where you see that a cleanup step does nothing:
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 /appThe 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:
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.lz4Notice 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:
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.
fastapi==0.115.12
uvicorn==0.34.2
psycopg2==2.9.10The naive version: 1.7 GB
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.
# 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-slimin both stages. The slim image has the interpreter and nothing else. The build stage addsgcc,libc6-devandlibpq-dev; none of them reach the runtime stage.- Cache mounts for apt and pip.
--mount=type=cachekeeps the apt archives and the pip wheel cache between builds without writing them into a layer. Deletingdocker-cleanis 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
libpq5and cleans the apt lists in the sameRUN, so the lists never land in a layer. - Code copied last.
requirements.txtis copied and installed before the application code, so editingapp/does not invalidate the dependency layers.
Result, same app, same dependencies:
| Image | Disk usage | Content size (pulled) |
|---|---|---|
demo-py:naive | 1.7 GB | 448 MB |
demo-py:multi | 223 MB | 53.8 MB |
Check that the native extension still links against the runtime library:
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
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.
# 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=devin its own stage. Installing production dependencies separately is cleaner than runningnpm pruneafter a full install, and BuildKit builds thedepsandbuildstages in parallel because neither depends on the other./root/.npmas a cache mount. npm’s download cache stays on the builder between runs and never enters a layer.- Distroless runtime.
gcr.io/distroless/nodejs22-debian12contains Node.js, glibc, CA certificates and little else: no shell, no package manager. Its entrypoint is already thenodebinary, soCMDis just the script path. The:nonroottag runs as UID 65532. - No shell means no
docker exec -it ... sh. Debug withkubectl debugand an ephemeral container, or build a:debugvariant of the image for local work. If your team relies on shelling into containers, usenode:22-bookworm-slimas 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.
| Image | Disk usage | Content size (pulled) |
|---|---|---|
demo-node:naive | 1.8 GB | 449 MB |
demo-node:multi | 210 MB | 53.3 MB |
docker run --rm --entrypoint sh demo-node:multi -c true
# exec: "sh": executable file not found in $PATHThat 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:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,target=/root/.npm \
npm ci --omit=devdocker 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.
.git
node_modules/
dist/
coverage/
__pycache__/
*.pyc
.venv/
.pytest_cache/
*.log
.env*
Dockerfile*
.dockerignoreLint 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:
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.