Docker for Python apps — small, fast, reproducible

Multi-stage builds, distroless bases, non-root users, and layer caching that survives CI.

🚚 Module 9 9 min read Not started

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

  1. Write a multi-stage Dockerfile for FastAPI.
  2. Use uv inside Docker for fast, reproducible builds.
  3. Pick the right base image.
  4. Apply security best practices (non-root, minimal deps).
  5. Handle native dependencies (PostgreSQL, Pillow, PyTorch).

1. The bad Dockerfile to avoid

dockerfile
FROM python:3.12
RUN pip install fastapi uvicorn
COPY . .
CMD ["python", "main.py"]

Problems:

  • python:3.12 is ~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)

dockerfile
# 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.lock copied separately so dep install is cached.
  • Cache mount: --mount=type=cache reuses uv's package cache across builds.
  • Non-root user: USER app (mitigates many container escapes).
  • Frozen lock: uv sync --frozen guarantees reproducibility.
  • No dev deps: --no-dev in production.

Image size: ~150 MB for a typical FastAPI + Pydantic + SQLAlchemy app. Build time after warm cache: ~5 seconds.


3. Base image choices

BaseSizeNotes
python:3.12~1 GBFull Debian. Don't use.
python:3.12-slim~150 MBDebian without doc/dev. Default sane choice.
python:3.12-alpine~50 MBmusl libc; smaller but slower wheels, occasional issues with C extensions.
gcr.io/distroless/python3-debian12~50 MBNo shell, no package manager. Maximum security; harder to debug.
ghcr.io/astral-sh/uv:python3.12-bookworm-slim~150 MBslim + 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:

dockerfile
# 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 ./src

Without this order, every code change re-installs all dependencies. With it, dep install is cached → ~5-second rebuilds.

BuildKit cache mounts

dockerfile
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen

The 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:

dockerfile
RUN groupadd -r app && useradd -r -g app app
USER app

If you must read/write files, chown explicitly:

dockerfile
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
.vscode

Without 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:

dockerfile
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-dev

In the runtime stage, install only runtime libs (not *-dev headers):

dockerfile
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:

dockerfile
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/cu124

Image 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

yaml
# 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:
powershell
docker compose up --build

For dev, mount source + override CMD with --reload.


10. Healthchecks

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -fsS http://localhost:8000/health || exit 1

Or expose a /health endpoint:

python
@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:

dockerfile
STOPSIGNAL SIGTERM

And in Kubernetes, give time for in-flight requests:

yaml
terminationGracePeriodSeconds: 30

For long-running tasks, propagate cancellation: check asyncio.CancelledError and return early.


12. Image scanning

Run a vulnerability scanner before pushing:

powershell
# Trivy
docker scan my-image:latest
trivy image my-image:latest

# Grype
grype my-image:latest

# Dockle (best practices)
dockle my-image:latest

Address CRITICAL/HIGH CVEs. Pin base image SHA digests for reproducibility:

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

dockerfile
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)

powershell
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

toml
# 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 = 1
powershell
fly launch                       # interactive setup
fly deploy                       # builds + ships
fly logs                         # follow logs
fly scale count 3                # 3 instances

End-to-end: code → Dockerfile → fly deploy → public URL. ~5 minutes for a fresh app.


Hands-on lab (2 hours)

  1. Containerise the FastAPI example from Lesson 8.1 using the "good" multi-stage Dockerfile.
  2. Measure: image size before and after multi-stage + slim base.
  3. Add a .dockerignore; rebuild and compare context size.
  4. Add a non-root user; verify with docker run --rm -it myimage id.
  5. Set up docker compose with Postgres + Redis; run end-to-end.
  6. Run trivy image on your image; fix any HIGH findings.
  7. Bonus: build a distroless variant; verify it runs.

Common pitfalls

  1. FROM python:3.12 (full image) — wasted hundreds of MB.
  2. COPY . . early — every code change invalidates dep cache.
  3. Running as root.
  4. Missing --no-cache-dir (pip) or cache mounts (uv) → bloated images.
  5. Not pinning the base image — python:3.12-slim floats; today's build ≠ next week's.
  6. Storing secrets in image layers (ENV API_KEY=...) — visible forever in docker history.
  7. Build context > 1 GB because of missing .dockerignore.

Self-check

  1. Why use multi-stage builds?
  2. What does --no-install-recommends do?
  3. Why prefer slim/distroless over the full Python image?
  4. When use Alpine vs Debian-slim?
  5. How does cache mount differ from layer cache?

References

Sign in to save your progress and earn badges.