---
title: 'Distroless Python Containers with uv: 2026 Tutorial'
description: 'Hands-on 2026 tutorial: build a tiny, secure Distroless Python container for a FastAPI app using uv 0.11.8, multi-stage builds, and a non-root runtime user.'
url: https://nerdleveltech.com/distroless-python-containers-with-uv-tutorial
language: en
date: '2026-05-04'
category: backend
tags:
  - python
  - docker
  - distroless
  - uv
  - fastapi
  - containers
  - devops
  - security
markdown_url: https://nerdleveltech.com/distroless-python-containers-with-uv-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/distroless-python-containers-with-uv-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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 service[^2], 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.0[^3] 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, 2026[^4]) 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 you[^5].

## 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.

```bash
# 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 upgrade[^1], 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.

```bash
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:

```python
# 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:

```bash
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`:

```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 symbols[^6]. 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 case[^6]. 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 filesystems[^6]. `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 cost[^6]. The `--mount=type=cache` syntax is BuildKit-only and persists uv's wheel cache across builds without bloating any final image layer[^7].

## Step 5: Build the image with BuildKit

```bash
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`:

```bash
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.

```bash
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:

```bash
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 Distroless[^8]. 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 table[^8].

Tear the container down before moving on:

```bash
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, but it was itself the target of a supply-chain attack on March 19, 2026: a threat actor used a compromised credential to publish a malicious `v0.69.4` binary release, and Docker Hub images tagged `0.69.5` and `0.69.6` were also found to contain the same credential-stealing payload[^3]. The attacker additionally attempted to push a malicious `v0.70.0` release, which Aqua stopped before it went out — so any `v0.70.0` artifact you encounter outside official channels should be treated as suspect. Aqua's official guidance is to use `v0.69.3` or earlier, or the legitimate `v0.70.0` that Aqua published on April 17, 2026 after rotating credentials and hardening its release pipeline[^3]. The compromise also hit Trivy's GitHub Actions: `aquasecurity/trivy-action` and `aquasecurity/setup-trivy` had their tags force-pushed to malicious commits, so pin `trivy-action` to `v0.35.0` (commit `57a97c7`) and `setup-trivy` to `v0.2.6` (commit `3fb12ec`) if you use either in CI, rather than trusting a floating tag[^3].

```bash
# 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](https://nerdleveltech.com/building-lightning-fast-ai-backends-with-fastapi-2026-edition) 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](https://nerdleveltech.com/kubernetes-security-best-practices-from-cluster-hardening-to-incident-response) for the cluster-level controls that pair with a non-root, shell-less image.

---

## Related reads

- [IoT Fundamentals: A Complete Guide for 2025](https://nerdleveltech.com/iot-fundamentals-a-complete-guide-for-2025)
- [Mastering Text Processing Automation: From Scripts to Scalable Pipelines](https://nerdleveltech.com/mastering-text-processing-automation-from-scripts-to-scalable-pipelines)
- [Python Tricks for Modern Frontend and Fog Computing Transitions](https://nerdleveltech.com/python-tricks-for-modern-frontend-and-fog-computing-transitions)
- [Docker Compose depends_on Not Waiting? Fix It (2026)](https://nerdleveltech.com/docker-compose-depends-on-not-waiting)


[^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]: 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, Aqua Security, March 19, 2026: https://github.com/aquasecurity/trivy/discussions/10425. The legitimate v0.70.0 release, April 17, 2026: https://github.com/aquasecurity/trivy/releases/tag/v0.70.0. Versions v0.69.4 (binary) and v0.69.5/v0.69.6 (Docker Hub images only, no corresponding GitHub release) were confirmed compromised and removed; a separately attempted malicious v0.70.0 tag was stopped before publication. Official safe-version guidance: https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23.
[^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/.
[^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.
[^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.
