Why single-stage Python images grow, and how to measure it

A Docker image is a stack of layers, and every RUN, COPY and ADD instruction adds one. Layers are additive. If one instruction installs gcc and the Python headers so that a package with C extensions can compile, and a later instruction removes them, the compiler still ships in the earlier layer. The image only hides it. The same applies to the pip or uv download cache, wheel build directories, .git, test fixtures, and anything else a broad COPY . . brings in. A single-stage image holds everything needed to build the application as well as everything needed to run it.

A multi-stage build uses more than one FROM in a single Dockerfile. The early stages compile and install. The final stage starts from a clean base and copies in only chosen paths from those stages. Layers from earlier stages are never part of the shipped image, so toolchains and caches never reach production. Beyond size, this also leaves fewer packages for scanners to flag and fewer tools for an attacker to use inside the container.

Measure before changing anything

Don't rely on size figures from blog posts, because base images, dependency sets and architectures all vary. Build your current image and run docker image ls to record its total size. Then run docker history <image> to see the size each instruction added. Add --no-trunc to see full commands. The large rows usually map to the toolchain install, the dependency install, and the source copy. Write down these numbers. Later sections compare against them.

#!/usr/bin/env bash
# Record a baseline for the current single-stage image before refactoring.
set -euo pipefail

IMAGE="myapi:single-stage"   # placeholder tag for the existing build

# Build the image exactly as it ships today.
docker build -t "$IMAGE" .

# Total size as reported by the local image store.
docker image ls "$IMAGE"

# Exact size in bytes, which is easier to compare than rounded values.
docker image inspect --format '{{.Size}}' "$IMAGE"

# Per-instruction breakdown. Large rows usually come from the
# toolchain install (gcc, headers), the dependency install, and COPY . .
docker history --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE"

# Full, untruncated commands, for checking what a large layer actually ran.
docker history --no-trunc "$IMAGE"

# Save the baseline so later sections can compare against it.
docker history --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE" > baseline-history.txt
docker image inspect --format '{{.Size}}' "$IMAGE" > baseline-size.txt

Builds the existing single-stage image, records its total size and the size each instruction added, and saves both as the baseline for later comparisons.

How it works: the virtual environment as the only artifact between stages

The pattern splits responsibilities cleanly. The builder stage owns everything needed to produce dependencies: compilers, headers, the package manager, its download cache, and lockfile resolution. It ends with a fully populated virtual environment at a fixed path such as /opt/venv. The runtime stage starts from a fresh base image and receives exactly two things via COPY --from=builder: that venv directory and your application code. Prepending /opt/venv/bin to PATH makes python, pip-installed console scripts like your ASGI server, and import resolution all use the venv without any activation step.

This works because a venv is mostly self-contained, but it is not relocatable. Three things must match across stages:

  • The venv path. Console scripts in bin/ have absolute shebangs pointing at /opt/venv/bin/python. Copy the venv to a different path and the scripts fail with a misleading "no such file or directory" error, because the file that is missing is the interpreter, not the script.
  • The Python minor version. bin/python is a symlink to the base interpreter, and pyvenv.cfg records its home. Packages live under lib/python3.X/site-packages, and compiled extensions carry ABI tags for that minor version. A mismatch produces a dangling symlink or ModuleNotFoundError.
  • The base distribution. Wheels built or selected against glibc will not load on a musl-based image. Even between glibc distributions, a missing shared library surfaces as an ImportError naming a .so file, and it appears only when the module is first imported.
+----------------------------------------------------------------+
| BUILDER  FROM python:3.X-<distro>                              |
|                                                                |
|   compilers, headers ---+                                      |
|   package manager ------+---> resolve lockfile ---> /opt/venv  |
|   download cache -------+                               |      |
|                                                         |      |
|   (discarded: never copied to runtime)                  |      |
+---------------------------------------------------------|------+
                                                          |
                 COPY --from=builder /opt/venv /opt/venv  |
                                                          v
+----------------------------------------------------------------+
| RUNTIME  FROM python:3.X-<same distro>                         |
|                                                                |
|   ENV PATH=/opt/venv/bin:$PATH   (no activation step)          |
|                                                                |
|   /opt/venv/bin/*         #!/opt/venv/bin/python               |
|   /opt/venv/bin/python    -> base interpreter 3.X              |
|   /opt/venv/lib/python3.X/site-packages  (ABI-tagged .so)      |
|   /app                    application code (COPY . /app)       |
+----------------------------------------------------------------+

 MUST MATCH ACROSS STAGES          WHAT BREAKS AT RUNTIME
 venv path  /opt/venv  ----------> shebang: "no such file or directory"
 Python     3.X minor  ----------> dangling bin/python, ModuleNotFoundError
 distro     glibc/musl ----------> ImportError on a .so at first import

The builder stage turns toolchains, caches and the lockfile into a venv at /opt/venv, which is the only dependency artifact copied into a runtime stage that must share the same venv path, Python minor version and base distribution.

Implementing the builder stage with uv, layer ordering and cache mounts

Ordering the builder for cache hits

Install uv only in the builder, either by copying its binary from the official uv image or with a pinned installer. Pin uv to an exact tag or digest. An unpinned uv can change resolver behaviour or flag semantics between two builds of the same commit. The uv CLI has also renamed and added options over time, so confirm the flags below against the uv documentation for the version you pin.

Docker invalidates a layer and every layer after it when that layer's inputs change. Copy pyproject.toml and uv.lock first, then run uv sync --locked --no-install-project --no-dev. --locked makes uv fail if the lockfile no longer matches pyproject.toml, so a stale lock breaks the build instead of resolving silently. (--frozen skips that check, which is weaker.) --no-install-project installs only third-party dependencies, so this expensive layer is rebuilt only when the lock changes. Next, copy the source and run uv sync --locked --no-dev again to install the project itself, which takes seconds.

Wrap both sync steps in RUN --mount=type=cache,target=/root/.cache/uv so downloaded wheels persist across builds without entering any layer. Because the cache mount sits on a separate filesystem, set UV_LINK_MODE=copy. If your CI runners have no persistent BuildKit cache, disable caching instead (UV_NO_CACHE=1, or --no-cache-dir for pip) so the cache doesn't inflate the builder layers.

Add a .dockerignore that excludes .git, .venv and other local venvs, __pycache__, .pytest_cache, coverage output and dist/. This keeps the build context small, and it stops a host venv from overwriting the one built in the image.

# syntax=docker/dockerfile:1

# Pin uv to an exact version tag (or a @sha256 digest) and pass it at build time:
#   docker build --build-arg UV_VERSION=<exact-tag> .
# Check the uv docs for that version to confirm the flags used below.
ARG UV_VERSION
FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv

FROM python:3.12-slim AS builder

# uv exists only in this stage. The runtime stage copies /app/.venv, not uv.
COPY --from=uv /uv /usr/local/bin/uv

# The cache mount is on a separate filesystem, so copy files instead of hardlinking.
# Use the base image's Python rather than letting uv download one.
ENV UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=0

WORKDIR /app

# Copy only the dependency manifests so the next layer is reused until the lock changes.
COPY pyproject.toml uv.lock ./

# Install third-party dependencies only. --locked fails the build on a stale uv.lock.
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-install-project --no-dev

# Source changes invalidate only the layers from here on.
# .dockerignore keeps .git, .venv, __pycache__ and test output out of this copy.
COPY . .

# Install the project itself. This step is fast because dependencies are already in place.
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev

# On CI runners without a persistent BuildKit cache, drop the --mount flags
# and set ENV UV_NO_CACHE=1 so no download cache is written into the layers.

A uv builder stage with a pinned uv version, dependencies installed before the project for better layer caching, --locked syncs that fail on a stale lockfile, and a BuildKit cache mount for uv's download cache.

Implementing the runtime stage: native libraries and hardening

The runtime stage starts from the same base image family as the builder, so the Python interpreter and glibc match. It installs only the shared libraries your compiled wheels load at runtime. If the builder compiled psycopg2 against libpq-dev, the runtime needs libpq5 and nothing else: no headers, no compiler, no -dev packages. Install them with --no-install-recommends and remove the apt lists in the same RUN so the cache never lands in a layer.

If you forget a library, docker build still succeeds. The failure shows up when the container starts, as ImportError: libpq.so.5: cannot open shared object file. Running ldd against the extension's .so files inside the venv lists every library the image is missing, which is faster than redeploying until the errors stop.

Next, create a system user without a login shell and switch to it with USER. The venv and code stay owned by root, so a compromised process cannot rewrite them. That ownership is also why PYTHONDONTWRITEBYTECODE=1 is set deliberately: the app user couldn't write .pyc files there anyway, so compile bytecode in the builder instead. PYTHONUNBUFFERED=1 sends log lines to stdout immediately, so the last lines before a crash still reach the log driver.

Copy only the venv and the application package. Leave out tests, scripts and the rest of the repository.

Finally, use exec-form CMD. Shell form wraps the server in /bin/sh -c, the shell becomes PID 1 and does not forward SIGTERM. In-flight requests are then cut off by SIGKILL when the stop timeout expires instead of draining cleanly.

# Runtime stage: same base family as the builder, so Python and glibc match.
FROM python:3.12-slim AS runtime

# Runtime shared library only (libpq5), never libpq-dev, headers or a compiler.
# Apt lists are removed in the same RUN so they never land in a layer.
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*

# Deliberate settings: no .pyc writes into root-owned dirs (bytecode was
# compiled in the builder); unbuffered output so final log lines are not lost.
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

# System user with no login shell and no home directory.
RUN groupadd --system app \
 && useradd --system --gid app --no-create-home --shell /usr/sbin/nologin app

# Copy only the venv and the application package. Both stay owned by root,
# so the app user can read and execute them but cannot modify them.
COPY --from=builder /opt/venv /opt/venv
COPY src/myapi /app/myapi
WORKDIR /app

USER app
EXPOSE 8000

# Exec form: uvicorn is PID 1 and receives SIGTERM directly for graceful shutdown.
CMD ["uvicorn", "myapi.main:app", "--host", "0.0.0.0", "--port", "8000"]

# Finding missing .so files before deploying (run from your shell):
#   docker run --rm --entrypoint sh myapi:latest -c \
#     'find /opt/venv -name "*.so" -exec ldd {} \; | grep "not found"'

A runtime stage that installs only libpq5, runs as a non-root user, sets the Python env vars on purpose, copies just the venv and app package, and uses exec-form CMD so SIGTERM reaches the server.

Choosing a runtime base image and the alternatives

The final stage's base image determines what ships to production. The builder stage only has to produce a virtual environment that the runtime stage can run. That environment is tied to the interpreter that created it: the same Python minor version, the same libc, and the same interpreter path that the venv's bin/python symlink points to. Pick the runtime base first, then make the builder match it.

Runtime base libc Shell Main trade-off
Slim Debian-based Python glibc Yes Recommended default. Prebuilt manylinux wheels install directly.
Alpine musl Yes Packages without musllinux wheels compile from source. This needs a toolchain in the builder, slows builds, and can produce a larger image.
Distroless-style glibc No Smaller attack surface. The interpreter path and version must match the builder exactly.
Single-stage slim, well-ordered glibc Yes One Dockerfile stage with dependencies installed before the code copy and caches cleaned. Simpler, but compilers stay in the image if any package needs them.

A single-stage slim image is a reasonable choice when every dependency installs from a wheel and no build tools are needed. In that case a second stage removes little.

Distroless-style images remove the shell, so docker exec ... sh no longer works. You have two ways to debug them. One is to build a debug variant that uses the same image with a busybox shell added, and run it locally or in staging. The other is to attach an ephemeral debug container to the running pod with kubectl debug. It shares the target's process namespace when you pass --target, so you can inspect the process from a full toolbox image. Production stays free of a shell either way.

Verifying the image: --target builds, size comparison and CI smoke tests

Docker's build has its own --target flag, unrelated to the kubectl debug one, and it is the fastest way to debug the build itself. Running docker build --target builder -t api:builder . stops after the builder stage, so you can open a shell in that image and inspect the virtual environment directly. Check that every package resolved, that compiled extensions exist under site-packages, and that the interpreter path in the venv's scripts matches the path you copy into the runtime stage. A mismatch there is the most common reason a multi-stage image builds cleanly but fails at startup.

Measure size on your own project

Build the old single-stage Dockerfile and the new one under different tags, then compare them with docker image ls. Run docker history on each to see which layers account for the bytes. The reduction depends almost entirely on your dependencies. A pure-Python API that previously shipped a compiler toolchain may shrink dramatically. An API dominated by large scientific or ML wheels may barely change, because those wheels are runtime payload in both versions. Record the measured number before you justify the migration with it.

Smoke test in CI

A smaller image is worthless if it cannot start. After the build, add two CI checks against the final image:

  • Import check: run python -c "import yourapp". This catches missing shared libraries and a wrong venv path.
  • Health check: start the container, poll the health endpoint until it responds or a timeout expires, and fail the job on a non-success status. Slim images often lack curl, so run the request from Python's standard library, either inside the container or from the CI host.

Gate the image push on both checks.

Who should use it, when to avoid it, and impact on cost and engineering time

The pattern pays off most for teams shipping Python APIs with compiled dependencies (database drivers, cryptography, image or numeric libraries), because the compiler toolchain and headers stay in the builder. It also suits services deployed many times a day, and environments where registry storage or node pull time is constrained, such as autoscaling clusters that pull images on cold nodes.

For a small internal service, or a stack dominated by large prebuilt wheels, the size reduction may be modest. A single slim stage with a frozen install is often enough.

Trade-off Why it matters
Venv-copy fragility The venv must land at the same path, with a matching Python interpreter, in both stages. A mismatch fails at runtime, not at build time.
Harder debugging Slim and distroless images lack shells and tools, so you need debug variants or ephemeral containers.
Cache mounts on ephemeral CI Without an exported build cache, every run starts cold and cache mounts give no benefit.
Lockfile strictness Frozen installs reject a stale lockfile, which adds developer friction.
uv as a tool uv is one more version to pin and update.
Review cost Multi-stage Dockerfiles take longer to review correctly.

The engineering-time return comes from two sources. Dependency-layer cache hits skip reinstalls when only application code changes. Frozen installs remove "works on my machine" drift between environments.

Illustrative estimate (assumed numbers, not a benchmark): time saved per week = rebuilds × (uncached install − cached install).

With 40 rebuilds × (180 s − 10 s) = 6,800 s, the saving is about 1.9 hours per week. Plug in your own rebuild counts and timings.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to docker multi-stage build python, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.