70. Shipping Python: containers, wheels, and GPU images
70.1 A production Dockerfile with uv
# syntax=docker/dockerfile:1
FROM python:3.14-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
WORKDIR /app
# 1. Dependencies only: this layer is rebuilt only when the lockfile changes.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-install-project --no-dev
# 2. Then the application code.
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev
FROM python:3.14-slim
RUN useradd --create-home --uid 10001 app
COPY --from=build --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
USER app
WORKDIR /app
EXPOSE 8000
# exec form: python is PID 1 and receives SIGTERM directly (Ch 34.3); uvicorn handles it gracefully
CMD ["uvicorn", "tickets.api:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "20"]
Why each line, cross-referenced with OS Ch 48 (writing a good Dockerfile):
- Multi-stage: build tools and caches stay in the first stage.
- Dependencies before code: editing your code does not re-install every package.
uv sync --locked: exactly the lockfile's versions and hashes, or the build fails (Ch 21, 50).-slim(Debian), not Alpine: manylinux wheels for NumPy, PyTorch, andcryptographytarget glibc; on musl-based Alpine, pip often has to compile them from source, slowly, if it can at all.- Non-root user,
PYTHONUNBUFFERED=1so logs appear immediately (Ch 33.3), exec-formCMDso signals arrive (Ch 34.3).
70.2 GPU images
For PyTorch on NVIDIA GPUs, start from a CUDA runtime base
(nvidia/cuda:12.x-cudnn-runtime-ubuntu24.04) or install the CUDA-enabled
torch wheel, which bundles the CUDA libraries it needs. The host needs only
the NVIDIA driver and the container toolkit. Images are large (several GB):
keep them for training and batch jobs, and serve exported models from a
lean runtime (llama.cpp, ONNX Runtime, or a Go/Rust service) where you can
(§0.5). Mount datasets and model weights as volumes or download them at
start-up; do not bake 20 GB of weights into an image.
70.3 Other ways to ship
| You ship | Use |
|---|---|
| a library | wheels on PyPI (or an internal index) via uv build + trusted publishing |
| a CLI for developers | uv tool install yourtool / pipx; a single-file script with inline dependencies (PEP 723) |
| a CLI for non-developers | a container, or a frozen binary (PyInstaller, Nuitka), knowing they are large |
| a service | a container image, run by Kubernetes (OS Ch 60) or systemd (Ch 38) |
| a trained model | weights in safetensors/GGUF/ONNX plus a model card; not a pickle (Ch 47.1) |
PEP 723 inline script metadata makes a single file runnable with its dependencies, which is perfect for operations scripts:
# /// script
# requires-python = ">=3.13"
# dependencies = ["httpx>=0.28", "rich>=14"]
# ///
import httpx
from rich import print
print(httpx.get("https://www.python.org", timeout=5).status_code)
uv run check.py # uv creates a cached environment with httpx and rich, then runs it