Distroless Python Containers with uv: 2026 Tutorial

May 4, 2026

Distroless Python Containers with uv: 2026 Tutorial

TL;DR

This tutorial walks through building a production-grade Python container that ships a FastAPI app on Google's gcr.io/distroless/python3-debian12:nonroot runtime, with dependencies resolved by uv 0.11.8 (released April 27, 2026)1 in a multi-stage Docker build. You will end up with a container that has no shell, no package manager, runs as UID 65532, and rebuilds in seconds thanks to BuildKit cache mounts. The whole thing is copy-paste-runnable in roughly fifteen minutes on Docker Desktop or any modern Linux host.

What You'll Learn

A Distroless Python container with uv removes the operating-system surface that most production CVEs exploit: there is no /bin/sh, no apt, no pip at runtime, only your code and the Python standard library. In this tutorial you will scaffold a FastAPI 0.136.1 service2, lock its dependencies with uv lock, build a two-stage image that precompiles Python bytecode through BuildKit cache mounts, copy the resulting virtual environment into the Distroless runtime stage, and verify the final image runs as the non-root 65532 user with docker inspect. Then you will scan the image with Trivy v0.70.03 to confirm a low-CVE baseline and see how to debug a Distroless image when there is no shell to drop into.

Prerequisites

Before you start, make sure your local environment matches these versions. Older Docker engines miss the BuildKit cache-mount syntax this tutorial relies on.

  • Docker Engine 23.0+ (Docker Desktop 4.19+ on macOS or Windows) so BuildKit is on by default and the --mount=type=cache syntax is available.
  • A POSIX shell. Examples below assume bash/zsh on macOS or Linux. On Windows, run them inside WSL2.
  • curl for the uv installer.
  • About 1 GB of free disk space for the build cache and intermediate layers.
  • No prior uv installation needed — Step 1 installs it.

We pin to the Python 3.11 series (latest is 3.11.15, released March 3, 20264) because Google's gcr.io/distroless/python3-debian12 runtime ships Debian 12's Python 3.11. Matching the builder's Python minor version to the runtime's is what keeps the venv working when copied across stages — a Python 3.13 venv would fail to start on a 3.11 runtime because libpython3.13.so is not present in Distroless. Patch versions inside 3.11.x are ABI-stable, so any 3.11.x in the builder is fine. You do not need a system-wide Python install for this tutorial, because uv will manage Python interpreters for you5.

Step 1: Install uv 0.11.8

Astral ships uv as a single static Rust binary, so the install is one command. Pin the version explicitly — latest is forbidden in production tutorials.

# macOS / Linux
curl -LsSf https://astral.sh/uv/0.11.8/install.sh | sh

# Verify
uv --version

Expected output:

uv 0.11.8 (revision …)

If you already have uv from an earlier version, run uv self update to move to 0.11.8. The 0.11.7 release on April 15, 2026 also bumped the bundled CPython build to include an OpenSSL security upgrade1, so anything older than 0.11.7 should be upgraded before continuing.

Step 2: Scaffold a FastAPI project with uv

Create a fresh project directory and let uv generate the layout.

mkdir distroless-fastapi-demo
cd distroless-fastapi-demo

uv init --python 3.11
uv add "fastapi==0.136.1" "uvicorn[standard]==0.46.0"

uv init writes pyproject.toml, a .python-version pin, and a hello.py. uv add resolves the dependency graph against the current Python and writes a uv.lock lockfile. Replace hello.py with a minimal FastAPI app:

# app.py
from fastapi import FastAPI

app = FastAPI(title="Distroless Demo", version="1.0.0")


@app.get("/healthz")
def healthz() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/")
def root() -> dict[str, str]:
    return {"message": "Hello from a Distroless Python container."}

Run it locally to confirm everything works before containerizing:

uv run uvicorn app:app --host 0.0.0.0 --port 8000

curl http://localhost:8000/healthz should return {"status":"ok"}. Stop the server with Ctrl+C.

Step 3: Add a .dockerignore

Docker copies your build context to the daemon. A missing .dockerignore is the single biggest reason "tiny" images end up at hundreds of megabytes. Create this file at the project root:

# .dockerignore
.git
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.ruff_cache
node_modules
.DS_Store
.env
.env.*
Dockerfile
.dockerignore
README.md

The .venv exclusion is the important one — uv creates a local venv that you do not want shipped to the build daemon.

Step 4: Write the multi-stage Dockerfile

This is the core of the tutorial. The build has two stages: a builder stage that runs on python:3.11-slim-bookworm with the uv 0.11.8 binary copied in from Astral's official image, and a runtime stage based on gcr.io/distroless/python3-debian12:nonroot that copies only the prebuilt virtual environment plus the application code. Both stages run on Debian 12 (bookworm), so glibc and libstdc++ versions match exactly between build and runtime — important when any of your Python dependencies ships compiled wheels.

Save this as Dockerfile:

# syntax=docker/dockerfile:1
# ---------- Stage 1: builder ----------
# Use the official Python 3.11 slim-bookworm image so the builder's
# Python series and glibc both match the Distroless python3-debian12
# runtime (both ship Debian 12's Python 3.11 and glibc 2.36).
# Astral's own ghcr.io/astral-sh/uv:*-python* images now default to
# trixie/Debian 13; mixing those with a debian12 runtime risks a
# glibc/libstdc++ mismatch at import time for any wheel that ships
# compiled C extensions.
FROM python:3.11-slim-bookworm AS builder

# Pull the uv 0.11.8 binaries straight from Astral's distroless image.
# This is Astral's recommended pattern for non-uv base images.
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/

# uv-specific environment for predictable Docker builds.
# UV_LINK_MODE=copy is required because the cache mount lives on a
# different filesystem than the target venv.
ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1 \
    UV_PYTHON_DOWNLOADS=never \
    UV_PROJECT_ENVIRONMENT=/app/.venv

WORKDIR /app

# Copy lockfile + pyproject only. This layer is cached
# until either file changes, so source-only edits skip
# dependency resolution entirely.
COPY pyproject.toml uv.lock ./

# Install dependencies (no project code yet) into /app/.venv.
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project --no-dev

# Now copy the app source and install the project itself.
COPY app.py ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

# ---------- Stage 2: runtime ----------
FROM gcr.io/distroless/python3-debian12:nonroot

WORKDIR /app

# Copy the prebuilt venv and the application source from the builder.
# --chown=nonroot:nonroot sets ownership to UID:GID 65532:65532.
COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv
COPY --from=builder --chown=nonroot:nonroot /app/app.py /app/app.py

# Make the venv interpreter the default Python.
ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Distroless ships with the "nonroot" account at UID:GID 65532.
# Use the numeric form so Kubernetes runAsNonRoot can verify it.
USER 65532:65532

EXPOSE 8000

# Distroless has no shell, so the command must be exec-form (a JSON array).
# Each element is passed directly to execve(2); there is no /bin/sh interpreting it.
CMD ["/app/.venv/bin/python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

A few rules baked into this file deserve a closer look. The builder uses python:3.11-slim-bookworm rather than one of Astral's prebuilt ghcr.io/astral-sh/uv:0.11.8-python... images because Astral replaced bookworm-based ghcr images with trixie (Debian 13) defaults back in uv 0.9, and Distroless python3-debian12 is still on bookworm. Bookworm ships glibc 2.36; trixie ships glibc 2.41 — running a trixie-built venv on a bookworm runtime can fail at import time with a GLIBC_2.X not found error if any wheel ships C extensions linked against newer glibc symbols6. Pulling the uv binary directly with COPY --from=ghcr.io/astral-sh/uv:0.11.8 is Astral's documented escape hatch for exactly this case6. The UV_LINK_MODE=copy line is required when uv's cache lives on a BuildKit cache mount because BuildKit places that cache on a separate overlay, and uv's default hardlink mode fails across filesystems6. UV_COMPILE_BYTECODE=1 precompiles every .py to .pyc during the build so the first request to your container does not pay a cold-start cost6. The --mount=type=cache syntax is BuildKit-only and persists uv's wheel cache across builds without bloating any final image layer7.

Step 5: Build the image with BuildKit

docker build -t distroless-fastapi-demo:1.0.0 .

The first build resolves the dependency graph and downloads wheels (under thirty seconds on a typical workstation). On a second build with no source changes, the entire dependency layer is served from the BuildKit cache and the build finishes in single-digit seconds. With only an app.py change, only the final two layers rebuild.

To inspect the layer breakdown, use docker history:

docker history distroless-fastapi-demo:1.0.0

You should see two distinct copy operations from the builder stage and no layers carrying apt indices, gcc, or build tools — none of those exist in the runtime stage at all.

Step 6: Verification — run, probe, inspect

Start the container and exercise the endpoints to confirm it actually serves traffic.

docker run -d --name distroless-demo -p 8000:8000 distroless-fastapi-demo:1.0.0

# Wait one second for uvicorn to bind, then probe:
curl -sS http://localhost:8000/healthz
# {"status":"ok"}

curl -sS http://localhost:8000/
# {"message":"Hello from a Distroless Python container."}

Now confirm the runtime user is 65532, not root:

docker inspect distroless-demo --format '{{.Config.User}}'
# 65532:65532

docker exec distroless-demo /app/.venv/bin/python -c "import os; print(os.getuid(), os.getgid())"
# 65532 65532

The numeric output confirms the process runs as the nonroot user defined inside Distroless8. This is exactly what Kubernetes' securityContext.runAsNonRoot: true policy requires — and because the user is numeric, Kubernetes can verify non-root status without a UID lookup table8.

Tear the container down before moving on:

docker rm -f distroless-demo

Step 7: Scan the image for vulnerabilities with Trivy

A Distroless base shrinks the attack surface, but it does not promise zero CVEs — your Python wheels and CPython itself still ship code. Aqua Security's Trivy is one of the most widely adopted open-source scanners for container images; pin v0.70.0, which is the first release after Aqua's March 2026 supply-chain incident3. Versions 0.69.4, 0.69.5, and 0.69.6 were retroactively confirmed compromised and must not be used3.

# Install Trivy v0.70.0 (Linux/macOS, brew tap pins to the safe release):
brew install aquasecurity/trivy/trivy

# Or pull the official image directly:
docker pull aquasec/trivy:0.70.0

# Scan the demo image:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
    aquasec/trivy:0.70.0 image distroless-fastapi-demo:1.0.0

Trivy reads the image's package metadata against vulnerability databases sourced from NVD, GitHub Advisories, Debian, Red Hat OVAL, and others, then prints a CVE table grouped by severity. On a fresh build with the versions in this tutorial, the Distroless runtime layer typically reports few or no high-severity CVEs — most findings, if any, will sit in your Python application dependencies, where you can fix them by bumping versions in pyproject.toml and re-running uv lock.

Step 8: Debug a Distroless image when something breaks

Distroless has no shell. docker exec -it container /bin/sh fails immediately because /bin/sh is not present in the runtime image — the OCI runtime returns a non-zero exit code (typically 126 or 127 depending on the runtime version and which lookup step fails first)9. There are two practical workarounds.

Use the :debug tag. Google ships a gcr.io/distroless/python3-debian12:debug-nonroot variant that adds a busybox shell while keeping the same library layout. For a one-off debug build, swap the FROM line in the runtime stage to the debug tag and rebuild — your Dockerfile is otherwise unchanged.

Use ephemeral debug containers. On Kubernetes 1.25+, kubectl debug -it my-pod --image=busybox --share-processes --copy-to=my-pod-debug attaches a busybox sidecar that shares the namespaces of the original Distroless container. You get a shell that can ls /proc/1/root/app to inspect the Distroless filesystem without rebuilding the image.

Both approaches keep the production image free of debugging tools and only introduce a shell when an operator explicitly opts in.

Troubleshooting

failed to compute cache key during build. You are on a Docker Engine older than 23.0 or BuildKit is disabled. Run DOCKER_BUILDKIT=1 docker build . or upgrade Docker Desktop to a version that ships Engine 23.0+.

uv sync --frozen fails with lockfile out of date. Your uv.lock was generated against a different Python or a different pyproject.toml. Run uv lock locally (without --frozen) and commit the regenerated lockfile, then rebuild.

Container exits immediately with little or no application output. When the OCI runtime cannot exec your CMD, you typically get a runtime-level message in docker logs (something like exec: "python": executable file not found in $PATH) but no Python traceback, because the interpreter never started. The most common root cause is shell-form CMD (CMD python -m uvicorn ...) — there is no /bin/sh in Distroless to interpret it. Always use exec-form (CMD ["python", "-m", "uvicorn", ...]).

OSError: [Errno 30] Read-only file system. Some Python libraries try to write into site-packages at import time. The Distroless filesystem is writable everywhere by default, but Kubernetes readOnlyRootFilesystem: true will trip this. Mount an emptyDir at the offending path (often /tmp or ~/.cache) in your pod spec.

uv: command not found inside the runtime container. Distroless does not include uv at runtime — that is intentional. uv lives only in the builder stage. Your runtime stage uses the prebuilt venv directly via /app/.venv/bin/python.

Next Steps

The image you just built is a strong default for any Python web service, but there are three obvious extensions worth exploring next. First, swap the python3-debian12 runtime for Chainguard's Wolfi-based Python image if your team needs a newer Python series than Debian 12 ships and a rolling-release CVE patch cycle. Second, if your service needs HTTP/2 (which uvicorn does not currently terminate natively), replace uvicorn with hypercorn and follow the same Dockerfile shape — uv resolves the swap as a one-line change in pyproject.toml. Third, layer in a real SBOM step: pass --format spdx-json to Trivy and publish the result alongside the image so downstream consumers can audit dependencies without re-scanning.

For deeper background on the runtime side, our earlier post on building lightning-fast AI backends with FastAPI covers async patterns, streaming, and uvicorn tuning that pair naturally with a Distroless deploy. The container hardening philosophy here also extends to non-Python workloads — see our Kubernetes security best-practices guide for the cluster-level controls that pair with a non-root, shell-less image.

Footnotes

  1. uv 0.11.8 release notes, Astral, April 27, 2026: https://github.com/astral-sh/uv/releases. uv 0.11.7 (April 15, 2026) bundled an OpenSSL security upgrade in the CPython 20260414 build. 2

  2. FastAPI 0.136.1 release, April 23, 2026: https://github.com/fastapi/fastapi/releases. The release bumped Starlette to 1.0.0 and added strict Content-Type checking on JSON requests.

  3. Trivy security incident discussion #10425 and v0.70.0 release, Aqua Security, April 17, 2026: https://github.com/aquasecurity/trivy/discussions/10425 and https://github.com/aquasecurity/trivy/releases. Versions v0.69.4, v0.69.5, and v0.69.6 were confirmed compromised and pulled. 2 3

  4. Python 3.11.15 release, March 3, 2026 — the final scheduled 3.11 bugfix release per PEP 664: https://www.python.org/downloads/release/python-31115/ and https://peps.python.org/pep-0664/. The latest Python releases overall as of writing are 3.13.13 and 3.14.4 (both April 7, 2026), but Distroless python3-debian12 ships Debian 12's Python 3.11, so this tutorial pins to 3.11.15 to keep the venv compatible with the runtime image.

  5. uv interpreter management documentation: https://docs.astral.sh/uv/concepts/python-versions/.

  6. uv Docker integration guide, including UV_LINK_MODE, UV_COMPILE_BYTECODE, and cache-mount patterns: https://docs.astral.sh/uv/guides/integration/docker/. 2 3 4

  7. Docker BuildKit cache-mount reference: https://docs.docker.com/build/cache/optimize/.

  8. Distroless nonroot user (UID:GID 65532:65532) and Kubernetes runAsNonRoot interaction: https://github.com/GoogleContainerTools/distroless/issues/443 and https://github.com/GoogleContainerTools/distroless/blob/main/SUPPORT_POLICY.md. 2

  9. Linux exit code conventions: 126 ("Command found but is not executable") vs 127 ("Command not found"). Documented in the GNU Bash Reference Manual: https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html. Docker exit-code mapping: https://docs.docker.com/reference/cli/docker/container/run/#exit-status.


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.