Most Python Docker images are bad in the same three ways at once: they’re huge, they rebuild from scratch on every code change because the dependency layer keeps invalidating, and they ship a full C compiler toolchain into production because nobody separated “what it takes to build this” from “what it takes to run this.” Ask why, and the answer is usually a single-stage FROM python:3.12 Dockerfile that pip installs requirements and copies the app in one undifferentiated block β a pattern that was fine for a weekend project and never got revisited once the image hit production.
The fix isn’t exotic. Multi-stage builds have existed in Docker since 2017, and uv has made the Python dependency-install step fast enough that the old excuse β “compiling everything from scratch is just slow, that’s Python” β no longer holds. What’s still missing for most teams is the actual mental model: which stage installs what, why the copy order in your Dockerfile determines whether your CI cache saves you five minutes or zero, and how to keep native extensions (psycopg2, numpy, cryptography) working when your runtime image doesn’t have the compiler that built them.
You’ll learn:
- Why single-stage Python Dockerfiles end up both bloated and slow to rebuild, and what specifically causes each problem
- How multi-stage builds separate “build-time” tooling from what actually ships to production
- How to wire up
uvfor dependency installation that’s genuinely faster thanpip, with BuildKit cache mounts that persist across builds - The exact Dockerfile instruction order that makes Docker’s layer cache actually save you time instead of silently missing
- How to keep native/compiled dependencies working when the builder and runtime stages use different base images
- How image size actually affects cold start time on Lambda, Cloud Run, and Kubernetes, and when it doesn’t matter at all
Table of Contents
- The Basics
- The Complete Architecture
- Core Layers Explained
- End-to-End Walkthrough
- Special Cases
- Scaling & Production Challenges
- Code Examples
- Common Pitfalls
- Production Best Practices
The Basics
What a Multi-Stage Build Actually Does
A multi-stage Dockerfile has more than one FROM line, and each one starts a fresh, isolated build stage. Later stages can selectively COPY --from=<earlier stage> specific files out of an earlier stage’s filesystem, but nothing else about that earlier stage β its installed packages, its cached downloads, its compiler toolchain β comes along unless you explicitly copy it. The final image is built only from the last FROM onward, plus whatever you pulled in with COPY --from.
That one mechanism solves both of the problems named above at once. You get a “builder” stage that’s allowed to be big and slow β install gcc, compile wheels, resolve a full dependency tree β and a “runtime” stage that only receives the finished, already-built artifacts: a populated virtual environment and your application code. The compiler never ships. The package manager’s download cache never ships. Only the output does.
Why This Matters Even If You Don’t Care About Image Size
- Attack surface. A production container with
gcc,make, andaptpackage lists installed is a bigger target than one with just a Python interpreter and your app. Removing build tooling from the runtime image is a real security improvement, not just a diet. - Cold start time. On Lambda, Cloud Run, and any autoscaling Kubernetes setup, the container image has to be pulled (and sometimes decompressed) before a new replica can serve traffic. A 180MB image starts materially faster than a 1.2GB one under scale-to-zero conditions.
- CI minutes are money. A Dockerfile that rebuilds your entire dependency tree because you changed one line of application code is burning CI time and developer patience on every single push.
The Complete Architecture
βββββββββββββββββββββββ ββββββββββββββββββββββββ
β builder stage β β runtime stage β
β FROM python:3.12 β β FROM python:3.12-slim β
β β copy β β
β gcc, build-essential β ββββββΊ β (no compiler) β
β uv sync β .venv/ β .venv β .venv/ (copied) β
β full source tree β app β app code only β
βββββββββββββββββββββββ ββββββββββββββββββββββββ
The guiding principle: the builder stage answers “what does it take to produce a working virtual environment and a compiled set of dependencies?” and the runtime stage answers “what does it take to run that environment?” Those are different questions with different answers, and collapsing them into one FROM line is the root cause of both bloat and slow rebuilds.
Core Layers Explained
1. The Base Image Choice
What it is: python:3.12 (the default Debian-based image), python:3.12-slim (a trimmed Debian without most dev headers and docs), and python:3.12-alpine (musl-based, smallest, but a different C library) are the three realistic starting points.
Why it matters: slim is the right default for the runtime stage almost every time. Alpine looks appealing on size alone, but musl libc breaks or slows down a surprising number of compiled Python packages (numpy, pandas, cryptography have all had real Alpine-specific issues over the years), and you frequently end up compiling from source on Alpine instead of installing a prebuilt manylinux wheel β which defeats the size win by making your build slower and, in some cases, your image not actually smaller once the compiled artifacts land.
Production tip: Use the full python:3.12 image (or python:3.12-bookworm explicitly, for reproducibility) as the builder stage where you need gcc anyway, and python:3.12-slim-bookworm as the runtime stage. Pin the Debian codename, not just the Python version β slim alone can silently move to a newer Debian release out from under you.
2. Installing Dependencies with uv Instead of pip
What it is: uv is a Rust-based Python package manager and resolver that’s a drop-in replacement for pip and pip-tools in most workflows, built by Astral (the same team behind ruff).
Why it matters: Dependency resolution and installation with plain pip is genuinely one of the slower parts of a Python image build, especially for larger dependency trees. uv parallelizes downloads, uses a global cache keyed by package hash so repeated installs across projects and images don’t re-download the same wheel, and resolves lockfiles an order of magnitude faster than pip-compile. In a Docker build specifically, that speed compounds with BuildKit cache mounts (below) to make a from-scratch dependency install go from minutes to seconds on a warm cache.
Production tip: Install with uv sync --frozen --no-install-project against a committed uv.lock file, not uv pip install -r requirements.txt. --frozen refuses to silently update the lockfile inside the container build β if your lockfile and pyproject.toml drift, you want that to fail loudly in CI, not resolve a different dependency set than what a developer tested locally.
3. BuildKit Cache Mounts for the Package Cache
What it is: RUN --mount=type=cache,target=/root/.cache/uv tells BuildKit to persist a directory across builds, separate from the image’s own layer cache β the contents live in a Docker-managed cache volume, not in any image layer, so they never bloat the final image but do survive between builds on the same machine (and across a shared BuildKit cache backend in CI).
Why it matters: Without this, every docker build β even with a hit on Docker’s regular layer cache β starts uv’s own download cache empty inside that layer’s execution. With it, a dependency that was downloaded on build #1 is already sitting in the mounted cache on build #50, even if the layer above it changed and forced a re-run of the install step.
Production tip: Use both caching mechanisms together, not one instead of the other β Docker’s layer cache to skip the install step entirely when nothing changed, and the BuildKit cache mount to make the install step itself fast on the rare occasions it does need to re-run.
4. Ordering Instructions for Real Cache Hits
What it is: Docker’s layer cache invalidates a layer, and every layer after it, the moment any earlier COPY or RUN in the Dockerfile changes. This means instruction order is not cosmetic β it directly determines whether editing app/routes.py triggers a full dependency reinstall or not.
Why it matters: If you COPY . . before running uv sync, then any file change β including a one-line comment in your application code β invalidates the dependency-install layer, and you rebuild your entire dependency tree on every single code change. That’s the single most common reason teams believe Docker builds for Python are inherently slow.
Production tip: Copy only pyproject.toml and uv.lock first, run the install, and only then copy the rest of the source tree. Dependency changes are far rarer than code changes, so this ordering means the expensive layer only re-runs when it actually needs to.
5. Non-Root Users and Minimal Runtime Surface
What it is: By default, a container process runs as root inside the container unless the image explicitly creates and switches to a non-root user with USER.
Why it matters: Running as root inside a container isn’t equivalent to root on the host, but it’s still an unnecessary privilege escalation surface if the process is ever compromised β and several managed platforms (Cloud Run with certain configurations, some Kubernetes Pod Security Standards) either warn on or outright reject root-user containers.
Production tip: Create the user in the runtime stage, not the builder stage, and chown the app directory to it explicitly β COPY --chown=appuser:appuser in the final COPY instruction handles this in one step without an extra RUN chown layer.
End-to-End Walkthrough
Trace a code change from git push to a running container:
- Developer edits
app/main.pyand pushes.pyproject.tomlanduv.lockare unchanged. - CI runs
docker build. BuildKit hashes each instruction’s inputs. TheCOPY pyproject.toml uv.lock ./andRUN uv synclayers hash identically to the last build β cache hit, both layers are skipped entirely. - The
COPY . .layer picks up the changedapp/main.py, invalidating from that point forward β but that’s the only work that needed to happen. - The runtime stage starts fresh from
python:3.12-slim, andCOPY --from=builder /app/.venv /app/.venvpulls in the already-built virtual environment β no reinstall happens in this stage at all, because the builder already did that work. - Final image is tagged and pushed. Because the runtime stage never had
gccor the uv cache in it to begin with, the image that ships is only the interpreter, the venv, and the app code. - Deploy triggers a pull on the target platform. A smaller image pulls faster, and if the platform is scale-to-zero (Cloud Run, Lambda container images), that pull time is sitting directly in the user-facing cold start.
Special Cases
Native/compiled dependencies (psycopg2, numpy, cryptography, pillow). These need real C libraries to compile against (libpq-dev for psycopg2, for example) and often a compiler. Install the -dev headers and build-essential only in the builder stage. If the runtime stage needs the shared library at runtime (not just at build time β libpq is a common example), install the non-dev runtime package (libpq5, not libpq-dev) in the runtime stage too; the compiled Python wheel links against it dynamically. Skipping this step produces a working build and an immediate ImportError or segfault at container start, which is a frustrating gap to debug the first time you hit it.
Private package indexes. If uv sync needs to reach a private PyPI mirror or a Git-based dependency behind auth, pass the credential as a build secret with --mount=type=secret, never as a ARG or ENV β build args land in image history and are recoverable with docker history, even for intermediate stages that don’t ship.
Deploying a FastAPI app specifically. If you’re containerizing a FastAPI service, the Dockerfile pattern here is identical, but where you put main.py, your routers, and your dependency-injection wiring inside the image matters for how cleanly this copy-order caching works β a project layout like the one in this guide to structuring a FastAPI project keeps application code cleanly separated from configuration, which is exactly the separation this Dockerfile pattern wants to exploit.
Multi-platform builds (linux/amd64 + linux/arm64). docker buildx build --platform linux/amd64,linux/arm64 builds both in one pass, but compiled dependencies without prebuilt manylinux_aarch64 wheels will compile from source on the arm64 leg, which is meaningfully slower in CI. Check your dependency tree’s wheel availability before assuming multi-platform builds are free.
Scaling & Production Challenges
CI cache doesn’t persist by default between ephemeral runners. BuildKit cache mounts are local to the machine running the build. On GitHub Actions or any ephemeral-runner CI, that cache is gone at the start of the next run unless you explicitly export it β docker buildx build --cache-to=type=registry,ref=<repo>:buildcache --cache-from=type=registry,ref=<repo>:buildcache pushes the cache to a registry so the next run, even on a brand-new runner, can pull it back.
Image size vs. layer count is a real tradeoff at scale. Combining RUN instructions with && reduces layer count, which helps pull time slightly, but it also means a change anywhere in that combined instruction invalidates the whole thing. For a Dockerfile that’s rebuilt dozens of times a day in CI, favoring more, smaller, well-ordered layers over fewer combined ones is usually the better tradeoff.
Reproducibility across builds. Pin your base image by digest (python:3.12-slim-bookworm@sha256:...), not just by tag, for anything you need bit-for-bit reproducible β tags are mutable and Debian security patches roll into slim images without a version bump you’d notice.
Code Examples
A complete multi-stage Dockerfile for a FastAPI service, using uv and BuildKit cache mounts:
# syntax=docker/dockerfile:1
FROM python:3.12-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv
WORKDIR /app
# Copy only dependency manifests first, so this layer (and the
# expensive install below) only invalidates when they actually change.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev
# Now copy the rest of the source and finish the install (this
# installs the project itself as an editable/non-editable package).
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM python:3.12-slim-bookworm AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
COPY --from=builder --chown=appuser:appuser /app /app
USER appuser
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Build it with the registry cache wired up for CI:
docker buildx build \
--platform linux/amd64 \
--cache-from type=registry,ref=myregistry.example.com/myapp:buildcache \
--cache-to type=registry,ref=myregistry.example.com/myapp:buildcache,mode=max \
-t myregistry.example.com/myapp:latest \
--push .
Check what actually shipped:
docker images myregistry.example.com/myapp:latest
docker history myregistry.example.com/myapp:latest --no-trunc
Common Pitfalls
Mistake: COPY . . before installing dependencies. This is the single biggest cause of “why does my Docker build take four minutes for a one-line change.” Solution: copy pyproject.toml/uv.lock first, install, then copy the rest.
Mistake: shipping the builder stage’s compiler and headers to production. Using a single FROM with no separate runtime stage means gcc, build-essential, and every -dev package you needed to compile a native extension end up in the deployed image. Solution: split into a builder and runtime stage; only copy the finished .venv and app code across.
Mistake: installing dev dependencies in the production image. uv sync without --no-dev installs your test framework, linter, and debugger into the runtime image. Solution: always pass --no-dev (or the equivalent extras exclusion) for the final production install.
Mistake: forgetting the runtime shared library for a compiled dependency. The wheel compiles and installs fine in the builder stage, then the app crashes on import in the runtime stage because libpq.so isn’t there. Solution: identify which runtime .so files a compiled dependency needs and install the corresponding non-dev system package in the runtime stage too.
Mistake: tagging images :latest only, with no digest pinning anywhere. This makes a “why did production suddenly behave differently” incident impossible to bisect. Solution: pin base images by digest for reproducibility, and tag built images with the Git SHA in addition to any moving :latest tag.
Production Best Practices
- Always separate build tooling from runtime. If a package needs
gccto install, thatgccbelongs in a stage that never ships. - Order Dockerfile instructions from least-frequently-changed to most-frequently-changed. Dependency manifests before source code, source code before anything environment-specific.
- Use
uvwith--frozenand a committed lockfile. Reproducible installs matter more in a container build than almost anywhere else, since it’s the one artifact that has to behave identically in CI, staging, and production. - Wire up a registry-backed BuildKit cache in CI. Local cache mounts are worthless on ephemeral runners without this.
- Run as a non-root user and pin base images by digest. Both are close to free and meaningfully reduce your image’s attack surface and non-reproducibility risk.
Wrapping Up
Small, fast, reproducible aren’t three separate goals that trade off against each other β they mostly fall out of the same handful of decisions: separate what builds the app from what runs it, order your Dockerfile so the cache actually protects you, and let uv and BuildKit cache mounts do the parallelizing and caching work that pip was never designed to do quickly. None of this requires exotic tooling or a rewrite; it’s a Dockerfile restructure you can do in an afternoon, and the payoff shows up on every single build afterward, not just the first one.
If your current Python image is still a single FROM python:3.12 with pip install -r requirements.txt sitting somewhere in the middle of a COPY . . block, that’s the one line worth fixing first β everything else in this post follows from splitting that apart.

