Skip to main content
Python Tooling2026-11-01 · 11 min read

Docker for Python in 2026: The Modern uv + Multi-Stage Build Guide

Open any five-year-old "Dockerize your Python app" tutorial and you will find the same four lines: FROM python:3.11-slim, COPY requirements.txt ., RUN pip install -r requirements.txt, COPY . .. It works. It also ships a 200MB-plus image, rebuilds slowly because every dependency install is a full cache miss the moment one line of application code changes, and quietly drags in a C compiler and header files your running container will never touch again after the build finishes.

None of that is necessary anymore. Between uv (the Rust-based package manager and installer from Astral), BuildKit cache mounts, and distroless base images, the 2026 default for a production Python container is a multi-stage build that ships only a Python interpreter, your virtual environment, and your code, nothing else. This guide walks through the old pattern, the new pattern, and a Dockerfile you can copy into a real project today.

The old pattern, and why it is expensive

DOCKERFILE
FROM python:3.13-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["python", "app.py"]

This pattern has three costs that compound as a project grows. First, python:3.13-slim already ships at roughly 150 to 200MB compressed before you add a single dependency, because "slim" still carries a full Debian userland, apt, and the Python standard library's C extensions. Second, every RUN pip install layer is cached by Docker's content hash of the preceding layers, so changing one line in app.py before COPY . . invalidates nothing upstream, but a change to requirements.txt invalidates the entire install step and re-downloads every wheel. Third, if any dependency needs a native build (a missing manylinux wheel, an unusual package version), pip quietly pulls in build tooling that then stays in the final image forever, because there is only one stage to throw it away in.

The new pattern: a uv builder stage plus a distroless final stage

The fix is a multi-stage build: one stage that has a compiler, uv, and your lockfile, and a second, much smaller stage that only copies the finished virtual environment and your source tree. The final stage never sees pip, uv, apt, or a C compiler.

DOCKERFILE
FROM python:3.13-slim AS builder COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never WORKDIR /app RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-project --no-dev COPY . /app RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev FROM gcr.io/distroless/python3-debian12 COPY --from=builder /app /app WORKDIR /app ENV PATH="/app/.venv/bin:$PATH" ENTRYPOINT ["/app/.venv/bin/python", "-m", "app"]

gcr.io/distroless/python3-debian12 has no shell, no package manager, and no pip, which is exactly what you want in production: there is nothing left in the container that an attacker who gets code execution could use to pull in more tools. The final image is your virtual environment's site-packages plus your code, which for a typical FastAPI service lands well under 50MB instead of 200MB-plus.

BuildKit cache mounts: never re-download the same wheel twice

The --mount=type=cache,target=/root/.cache/uv line is doing the real work here. Without it, every docker build on a fresh runner re-downloads every wheel from PyPI, because the layer cache only survives if the preceding layers, including COPY uv.lock, are byte-identical. A BuildKit cache mount is a persistent volume outside the layer graph: it survives across builds on the same host, or, with a registry cache backend, across CI runners, even when the lockfile changes. Pair it with uv sync --frozen, which refuses to touch the lockfile and fails loudly if pyproject.toml and uv.lock are out of sync, instead of silently resolving a different dependency tree than the one your tests ran against.

Splitting the uv sync --no-install-project step from the final uv sync is the other half of the trick: the first call installs only third-party dependencies (cached as long as uv.lock does not change), and the second call, run after COPY . /app, installs your own project without re-touching third-party packages. Change one line of application code and the dependency layer stays cached.

Native dependencies: numpy, pillow, and the manylinux problem

Packages like numpy and Pillow ship prebuilt manylinux wheels for the common platforms, so on linux/amd64 or linux/arm64 uv sync normally just downloads a wheel and never touches a compiler. The failure mode shows up when you are on an uncommon architecture, pin a very new release before wheels catch up, or depend on a package that only ships a source distribution. In that case the builder stage needs build tooling the final stage must never see:

DOCKERFILE
FROM python:3.13-slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends build-essential \ && rm -rf /var/lib/apt/lists/* # uv + sync steps as in the stage above

build-essential (gcc, make, libc headers) lives only in the builder stage. The distroless final stage copies the already-compiled .venv, never the compiler that built it. If you want to practice building and shipping a real containerized service end to end, the FastAPI course walks through exactly this kind of API, from the first endpoint to a production-shaped deployment.

uv-managed Python: stop depending on whatever interpreter the base image shipped

python:3.13-slim gives you whatever patch release Debian decided to ship that week. uv can manage the interpreter itself instead: uv python install 3.13.1 downloads a specific, reproducible CPython build from the python-build-standalone project and pins it in .python-version, so your builder stage and your laptop run the exact same interpreter down to the patch version. Combined with UV_PYTHON_DOWNLOADS=never in the Dockerfile, so a mis-pinned version fails the build loudly instead of silently downloading something different, this closes the "works on my machine, fails on the runner" gap that a floating base-image tag leaves open.

Debugging a shell-less container

The one real cost of distroless: there is no shell, so docker exec -it container sh does not work. For local debugging, build the same Dockerfile with a target that stops at the builder stage (docker build --target=builder -t myapp:debug .), which still has python:3.13-slim's full userland and a shell. Keep that debug target out of your deploy pipeline; it exists for your terminal, not for production.

The one-file template

DOCKERFILE
FROM python:3.13-slim AS builder COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never WORKDIR /app RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-project --no-dev COPY . /app RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev FROM gcr.io/distroless/python3-debian12 COPY --from=builder /app /app WORKDIR /app ENV PATH="/app/.venv/bin:$PATH" ENTRYPOINT ["/app/.venv/bin/python", "-m", "app"]

Copy this, change the ENTRYPOINT module name to match your project layout, and you have a build that is smaller, faster on repeat builds, and harder to attack than the pip install -r requirements.txt pattern most tutorials still teach. Ready to practice the backend that goes inside this container? Start with FastAPI.

FAQ

Do I need BuildKit enabled for the cache mount to work?

Yes, --mount=type=cache is a BuildKit-only feature. It has been the default builder in Docker Desktop and recent Docker Engine releases, but on older CI runners you may need DOCKER_BUILDKIT=1 set explicitly, or docker buildx build instead of plain docker build.

Why not just use python:3.13-alpine for a smaller image?

Alpine's musl libc breaks the manylinux wheel assumption that numpy, Pillow, and most compiled packages rely on, so you lose prebuilt wheels and either compile from source or hit subtle runtime bugs. distroless-glibc avoids that trap.

Does uv replace pip and Poetry completely?

For most new projects, yes. uv resolves and installs faster, reads the same pyproject.toml, and generates a lockfile, but if a team already depends on Poetry plugins or a workflow uv does not yet cover, there is no urgency to migrate mid-project. Try uv on a new service first.

What if my base image needs a security patch after I have already built the image?

Rebuild. Pin a digest, not just a tag, for python:3.13-slim in the builder stage, and rebuild on a schedule, weekly is reasonable for most teams, so a patched base image flows into a new build instead of sitting stale for months.

Can I use this pattern for a Django project instead of FastAPI?

Yes, the Dockerfile does not care which framework you run; only the final ENTRYPOINT changes (for example gunicorn myproject.wsgi:application). The FastAPI course is a fast way to practice the pattern end to end if you are starting a new service.

Is distroless production-ready, or is it still experimental?

It is widely used in production; Google maintains it and many teams ship it as their default Python base. The tradeoff to accept going in is the lack of a shell, which is a deliberate security choice, not a bug.

Ready to try it?

15 lessons free after a free signup — no card. Sample the platform, then decide.

Start free →

Or unlock every lesson

Pro — $12/mo, unlimited AI, cancel any time. Refund within 14 days worldwide.

See Pro plans →

Get one Python lesson + one career idea every Friday

No spam, no "buy our course now". Three bullets, every Friday. Unsubscribe with one click.