Docker for Python apps — small, fast, reproducible
Multi-stage builds, distroless bases, non-root users, and layer caching that survives CI.
Why this matters
You ship Python in containers — to Kubernetes, ECS, Cloud Run, Lambda, Fly.io. A poorly-built image is 2 GB, takes 90 seconds to start, and is full of CVEs. A well-built one is 100 MB, starts in 1 second, and is reproducible across machines. This lesson teaches the modern Dockerfile patterns for Python.
Learning objectives
- Write a multi-stage Dockerfile for FastAPI.
- Use
uvinside Docker for fast, reproducible builds. - Pick the right base image.
- Apply security best practices (non-root, minimal deps).
- Handle native dependencies (PostgreSQL, Pillow, PyTorch).
1. The bad Dockerfile to avoid
FROM python:3.12
RUN pip install fastapi uvicorn
COPY . .
CMD ["python", "main.py"]Problems:
python:3.12is ~1 GB (full Debian).- No layer cache for deps (pip runs every code change).
- Runs as root.
- No frozen versions.
- No multi-stage; build deps end up in the final image.
We can do better.
2. The good Dockerfile (uv + multi-stage)
# syntax=docker/dockerfile:1.7
# ============ Builder ============
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
# 1. Install deps (cached layer)
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev
# 2. Install the project itself (cheap layer)
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
# ============ Runtime ============
FROM python:3.12-slim-bookworm
WORKDIR /app
# Create non-root user
RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app
# Copy ONLY the venv + code from the builder
COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
USER app
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]What's good:
- Multi-stage: builder has uv + build tools; final image has only python + venv.
- Layer cache:
pyproject.toml + uv.lockcopied separately so dep install is cached. - Cache mount:
--mount=type=cachereuses uv's package cache across builds. - Non-root user:
USER app(mitigates many container escapes). - Frozen lock:
uv sync --frozenguarantees reproducibility. - No dev deps:
--no-devin production.
Image size: ~150 MB for a typical FastAPI + Pydantic + SQLAlchemy app. Build time after warm cache: ~5 seconds.
3. Base image choices
| Base | Size | Notes |
|---|---|---|
python:3.12 | ~1 GB | Full Debian. Don't use. |
python:3.12-slim | ~150 MB | Debian without doc/dev. Default sane choice. |
python:3.12-alpine | ~50 MB | musl libc; smaller but slower wheels, occasional issues with C extensions. |
gcr.io/distroless/python3-debian12 | ~50 MB | No shell, no package manager. Maximum security; harder to debug. |
ghcr.io/astral-sh/uv:python3.12-bookworm-slim | ~150 MB | slim + uv pre-installed. Use as builder. |
Recommendation in 2026: slim-bookworm (Debian 12) for runtime; astral-sh/uv image for builder.
Avoid Alpine for ML / data workloads — musl libc creates compatibility issues with manylinux wheels, sometimes forcing source builds.
4. Caching strategy
Each COPY invalidates downstream layers. Order from least-changing to most-changing:
# 1. System packages (rare)
RUN apt-get update && apt-get install -y libpq-dev && rm -rf /var/lib/apt/lists/*
# 2. Python deps (changes on lock changes)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# 3. Application code (changes most)
COPY src ./srcWithout this order, every code change re-installs all dependencies. With it, dep install is cached → ~5-second rebuilds.
BuildKit cache mounts
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozenThe mount caches uv's package downloads across builds (even across different repos). Enable BuildKit: DOCKER_BUILDKIT=1 (default in modern Docker).
5. Non-root user
Many corp / k8s clusters refuse to run containers as root. Always:
RUN groupadd -r app && useradd -r -g app app
USER appIf you must read/write files, chown explicitly:
COPY --chown=app:app . .Or write only to ephemeral /tmp (always writable, even read-only filesystems).
6. .dockerignore
.git
.gitignore
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.ruff_cache
htmlcov
tests/
docs/
Dockerfile
docker-compose.yml
README.md
.env
.vscodeWithout this, COPY . . slurps your .git directory, secrets, virtualenvs, and test data. Slow builds and security risk.
7. Native deps — PostgreSQL, Pillow, lxml
System libraries via apt:
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libjpeg-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-devIn the runtime stage, install only runtime libs (not *-dev headers):
FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 libjpeg62-turbo \
&& rm -rf /var/lib/apt/lists/*Always --no-install-recommends + rm -rf /var/lib/apt/lists/* to keep size small.
8. PyTorch / CUDA images
For GPU workloads, start from NVIDIA's CUDA-enabled Python base:
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y python3.12 python3-pip git \
&& rm -rf /var/lib/apt/lists/*
RUN pip install torch --index-url https://download.pytorch.org/whl/cu124Image size: ~5 GB (CUDA libs are big). Smaller variants: nvidia/cuda:*-runtime-* (vs -devel which has full toolchain).
For LLM inference at scale, use vllm / tgi official images — they handle all this and ship optimised kernels.
9. docker-compose for development
# docker-compose.yml
services:
api:
build: .
ports: ["8000:8000"]
volumes:
- ./src:/app/src # live reload for dev (combine with --reload)
environment:
- DATABASE_URL=postgresql+asyncpg://app:app@db:5432/app
depends_on: [db, redis]
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes: [pgdata:/var/lib/postgresql/data]
ports: ["5432:5432"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes:
pgdata:docker compose up --buildFor dev, mount source + override CMD with --reload.
10. Healthchecks
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1Or expose a /health endpoint:
@app.get("/health")
async def health():
# Optionally check DB / Redis
return {"status": "ok"}For Kubernetes: liveness (am I alive?) + readiness (can I serve traffic?) probes.
11. Signals + graceful shutdown
uvicorn handles SIGTERM (sent by k8s on pod termination). FastAPI's lifespan runs its shutdown block. Set:
STOPSIGNAL SIGTERMAnd in Kubernetes, give time for in-flight requests:
terminationGracePeriodSeconds: 30For long-running tasks, propagate cancellation: check asyncio.CancelledError and return early.
12. Image scanning
Run a vulnerability scanner before pushing:
# Trivy
docker scan my-image:latest
trivy image my-image:latest
# Grype
grype my-image:latest
# Dockle (best practices)
dockle my-image:latestAddress CRITICAL/HIGH CVEs. Pin base image SHA digests for reproducibility:
FROM python:3.12-slim-bookworm@sha256:abcd1234...13. Distroless images (advanced)
Google's distroless images contain only Python + your code — no shell, no apt, no package manager. Minimal attack surface.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY src ./src
RUN uv sync --frozen --no-dev
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Pros: smallest, most secure.
Cons: can't docker exec -it sh — no shell. Debug via sidecar containers.
For high-security environments, distroless is best practice in 2026.
14. Multi-arch builds (Apple Silicon + servers)
docker buildx create --use
docker buildx build --platform linux/amd64,linux/arm64 -t myimage:latest --push .buildx builds for both architectures and pushes a manifest. Mac M-series devs and AWS Graviton servers all pull the right variant.
15. Worked example: deploy FastAPI to Fly.io
# fly.toml (generated by `fly launch`)
app = "my-api"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
PORT = "8000"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
min_machines_running = 1fly launch # interactive setup
fly deploy # builds + ships
fly logs # follow logs
fly scale count 3 # 3 instancesEnd-to-end: code → Dockerfile → fly deploy → public URL. ~5 minutes for a fresh app.
Hands-on lab (2 hours)
- Containerise the FastAPI example from Lesson 8.1 using the "good" multi-stage Dockerfile.
- Measure: image size before and after multi-stage + slim base.
- Add a
.dockerignore; rebuild and compare context size. - Add a non-root user; verify with
docker run --rm -it myimage id. - Set up
docker composewith Postgres + Redis; run end-to-end. - Run
trivy imageon your image; fix any HIGH findings. - Bonus: build a distroless variant; verify it runs.
Common pitfalls
FROM python:3.12(full image) — wasted hundreds of MB.COPY . .early — every code change invalidates dep cache.- Running as root.
- Missing
--no-cache-dir(pip) or cache mounts (uv) → bloated images. - Not pinning the base image —
python:3.12-slimfloats; today's build ≠ next week's. - Storing secrets in image layers (
ENV API_KEY=...) — visible forever indocker history. - Build context > 1 GB because of missing
.dockerignore.
Self-check
- Why use multi-stage builds?
- What does
--no-install-recommendsdo? - Why prefer slim/distroless over the full Python image?
- When use Alpine vs Debian-slim?
- How does cache mount differ from layer cache?
References
- Docker docs: https://docs.docker.com/.
astral-sh/uvDocker image: https://docs.astral.sh/uv/guides/integration/docker/.- Distroless images: https://github.com/GoogleContainerTools/distroless.
- Container Security, Liz Rice.
- Snyk container best practices.
- Caddy / Traefik docs for reverse proxies.
Sign in to save your progress and earn badges.