fastapi-uv-docker

This commit is contained in:
John Lancaster
2026-06-17 00:24:37 -05:00
parent 57da7e001e
commit 78fa9235d3
4 changed files with 1227 additions and 0 deletions
@@ -0,0 +1,345 @@
# Docker and Cloud-Native Patterns
Source: https://docs.docker.com/build/building/best-practices/ | https://docs.astral.sh/uv/guides/integration/docker/ | https://fastapi.tiangolo.com/deployment/docker/
---
## Canonical Multi-Stage Dockerfile (uv + FastAPI + uvicorn)
```dockerfile
# syntax=docker/dockerfile:1
# ── builder stage ─────────────────────────────────────────────────────────────
FROM python:3.12-slim-bookworm AS builder
# Pin uv version — never use :latest in production
COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /uvx /bin/
WORKDIR /app
# Layer: install dependencies only (cached until pyproject.toml or uv.lock changes)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev --no-editable
# Layer: copy source and install project
COPY src/ /app/src/
COPY pyproject.toml uv.lock /app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-editable
# ── runtime stage ─────────────────────────────────────────────────────────────
FROM python:3.12-slim-bookworm AS runtime
# Non-root user with explicit UID/GID
RUN groupadd --system --gid 1001 appgroup && \
useradd --system --uid 1001 --gid appgroup --no-log-init --home /app appuser
WORKDIR /app
# Copy only the venv (uv binary stays in builder)
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
# Copy app source
COPY --from=builder --chown=appuser:appgroup /app/src /app/src
# Activate the venv via PATH — do not rely on `uv run` at runtime
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_PYTHON_DOWNLOADS=0
USER appuser
EXPOSE 8000
# Liveness probe — no extra tools needed; uses stdlib urllib
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD python -c \
"import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" \
|| exit 1
# Exec form (NOT shell form) — required for SIGTERM → graceful shutdown
CMD ["uvicorn", "my_app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--workers", "1", \
"--proxy-headers", \
"--forwarded-allow-ips", "*"]
```
---
## .dockerignore
```
# Python artifacts
.venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
.ruff_cache/
dist/
*.egg-info/
# Environment and secrets
.env
.env.*
!.env.example
# Git
.git/
.gitignore
# Docs and local tooling
README.md
docs/
*.md
```
**Always add `.venv/` to `.dockerignore`** — it is platform-specific and will cause subtle failures if copied into the image.
---
## docker-compose.yml (Local Development)
```yaml
services:
app:
build:
context: .
target: runtime # build only up to the runtime stage
ports:
- "8000:8000"
environment:
DEBUG: "true"
LOG_LEVEL: "debug"
env_file:
- .env
restart: unless-stopped
# Docker Compose Watch — hot reload without rebuilding image
develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- .venv/
- __pycache__/
- action: rebuild
path: ./pyproject.toml
- action: rebuild
path: ./uv.lock
```
Run with:
```bash
docker compose up # start normally
docker compose watch # start with live sync
docker compose up --build # force rebuild
```
---
## Key Dockerfile Rules
### Multi-Stage Builds
- **Stage 1 (`builder`)**: Has uv, build tools, compiles `.pyc` files if needed.
- **Stage 2 (`runtime`)**: Minimal; contains only the venv and source. No uv, no build tools.
- Final image size: typically 150200 MB for a FastAPI app (vs 500+ MB with a single stage).
### Layer Caching Strategy
Copy files in order of change frequency (least → most):
```
1. uv.lock + pyproject.toml → install deps (cached for days)
2. Source code → install project (invalidated on every code change)
```
This means dependency installation is only re-run when `uv.lock` or `pyproject.toml` changes.
### CMD Exec Form (Critical)
```dockerfile
# ✅ Exec form — process receives SIGTERM directly → graceful shutdown
CMD ["uvicorn", "my_app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# ❌ Shell form — process is a child of /bin/sh → SIGTERM goes to shell, not uvicorn
CMD uvicorn my_app.main:app --host 0.0.0.0 --port 8000
```
Shell form breaks graceful shutdown and FastAPI lifespan shutdown events.
### Non-Root User
```dockerfile
RUN groupadd --system --gid 1001 appgroup && \
useradd --system --uid 1001 --gid appgroup --no-log-init --home /app appuser
USER appuser
```
- Use `--system` for service accounts (no shell, no home by default).
- Use `--no-log-init` to avoid `/var/log/faillog` disk exhaustion (Go runtime bug in older kernels).
- Use explicit UID/GID (1001) — deterministic, scanners can reason about it.
- Never run as UID 0 (root) in production.
### HEALTHCHECK
```dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD python -c \
"import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" \
|| exit 1
```
- `--start-period`: grace period on startup before health checks begin.
- `--retries`: mark as unhealthy only after N consecutive failures.
- Uses stdlib `urllib` — no extra tools needed, not even `curl`.
---
## Cloud-Native Twelve-Factor Principles Applied
### Factor III — Config (Environment Variables)
- All config comes from environment variables, never from code.
- `pydantic-settings` reads from env automatically.
- Provide `.env.example` with documentation; never commit `.env`.
```bash
# Runtime injection
docker run -e DATABASE_URL="postgresql://..." -e SECRET_KEY="..." my-app
# or via env_file in compose / Kubernetes Secret
```
### Factor XI — Logs (Treat as Event Streams)
- Set `PYTHONUNBUFFERED=1` — logs are flushed immediately to stdout/stderr.
- Never write logs to files inside the container.
- Configure uvicorn to log JSON in production:
```python
# In create_app() lifespan or a logging setup module
import logging
import json
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
return json.dumps({
"level": record.levelname,
"message": record.getMessage(),
"logger": record.name,
})
```
Or use `structlog` with the JSON renderer for production.
### Factor IX — Disposability (Fast Startup, Graceful Shutdown)
- FastAPI lifespan handles startup/shutdown.
- Uvicorn forwards `SIGTERM` to the Python process when using exec form `CMD`.
- The process should be fully ready to serve requests within 10 seconds.
- Kubernetes `terminationGracePeriodSeconds` should be >= your timeout.
---
## Kubernetes Readiness / Liveness Probes
Expose two endpoints:
```python
@router.get("/healthz", include_in_schema=False) # liveness
async def health():
return {"status": "ok"}
@router.get("/readyz", include_in_schema=False) # readiness
async def readiness(request: Request):
# Check DB, cache, etc.
return {"status": "ready"}
```
Kubernetes manifest snippet:
```yaml
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
```
---
## Scaling: Workers vs. Replicas
| Deployment | Workers setting | Reasoning |
|---|---|---|
| Kubernetes / Cloud Run | `--workers 1` | Orchestrator handles replication; 1 process per container for predictable memory |
| Docker Compose (single host) | `--workers 4` | No external replication; use CPU cores |
| Local dev | `--reload` (single process) | Hot reload only works with single process |
**Never use `--reload` in production.**
---
## Base Image Selection
| Use case | Recommended base |
|---|---|
| Standard production | `python:3.12-slim-bookworm` |
| Smallest possible image | `python:3.12-alpine3.20` (musl; watch for C extension compat) |
| uv-managed Python | `ghcr.io/astral-sh/uv:python3.12-bookworm-slim` |
| Security-hardened | `cgr.dev/chainguard/python:latest` (distroless) |
**Do not use:**
- `python:latest` — unpinned, breaks reproducibility
- `tiangolo/uvicorn-gunicorn-fastapi` — deprecated by FastAPI team
- Full `python:3.x` (non-slim) — 300+ MB unnecessary overhead
---
## Build Optimizations
```bash
# Enable BuildKit (default in Docker >= 23)
export DOCKER_BUILDKIT=1
# Build with cache mount (fastest for repeated local builds)
docker build -t my-app .
# CI: force fresh base image + no layer cache
docker build --pull --no-cache -t my-app .
# Multi-platform build (for ARM deployment from x86 CI)
docker buildx build --platform linux/amd64,linux/arm64 -t my-app .
```
---
## Security Hardening Checklist
- [ ] Non-root user with explicit UID/GID
- [ ] Read-only filesystem where possible (`--read-only` docker run flag or `securityContext.readOnlyRootFilesystem: true` in K8s)
- [ ] No secrets in `ENV` Dockerfile instructions
- [ ] Minimal base image (slim/alpine)
- [ ] Multi-stage build (no build tools in runtime image)
- [ ] Pin base image and uv version (not `:latest`)
- [ ] `.dockerignore` excludes `.env`, `.git`, `.venv`
- [ ] `EXPOSE` only the port actually used
- [ ] Regular base image updates in CI (`docker build --pull`)
@@ -0,0 +1,238 @@
# FastAPI Best Practices
Source: https://fastapi.tiangolo.com/deployment/ | https://fastapi.tiangolo.com/advanced/events/
---
## App Factory Pattern
Always use `create_app()` — it makes the app testable (inject a test `Settings`) and avoids module-level side effects.
```python
# src/my_app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from my_app.config import Settings
from my_app.api import health, v1
def create_app(settings: Settings | None = None) -> FastAPI:
if settings is None:
settings = Settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- startup ---
app.state.settings = settings
# Open DB pool, warm caches, etc.
yield
# --- shutdown ---
# Close DB pool, flush buffers, etc.
app = FastAPI(
title=settings.app_name,
version="1.0.0",
lifespan=lifespan,
docs_url="/docs" if settings.debug else None,
redoc_url=None,
)
app.include_router(health.router)
app.include_router(v1.router, prefix="/api/v1")
return app
# Module-level instance for uvicorn
app = create_app()
```
**Never use `@app.on_event("startup")` / `@app.on_event("shutdown")`** — these are deprecated. The `asynccontextmanager` lifespan is the canonical approach since FastAPI 0.95.
---
## Router Organization
```
src/my_app/api/
├── __init__.py
├── health.py # GET /healthz — no auth, no versioning
├── deps.py # Shared Depends() factories
└── v1/
├── __init__.py # APIRouter with prefix="/v1"
├── items.py
└── users.py
```
Each router file:
```python
from fastapi import APIRouter
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/")
async def list_items() -> list[Item]:
...
```
Root registration:
```python
from fastapi import APIRouter
from my_app.api.v1 import items, users
router = APIRouter()
router.include_router(items.router)
router.include_router(users.router)
```
---
## Pydantic Settings (Configuration)
```python
# src/my_app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "My App"
debug: bool = False
host: str = "0.0.0.0"
port: int = 8000
log_level: str = "info"
# Add DB URL, secret keys, etc. here — never hardcode
# database_url: str # required — will raise if missing
@lru_cache
def get_settings() -> Settings:
return Settings()
```
Use `lru_cache` so the `.env` file is read once. In tests, override with:
```python
from my_app.config import get_settings
from my_app.main import create_app
app = create_app(settings=Settings(debug=True, database_url="sqlite://"))
```
**Never** import `settings` as a module-level singleton — it prevents test overrides.
---
## Health Endpoint
```python
# src/my_app/api/health.py
from fastapi import APIRouter
from fastapi.responses import JSONResponse
router = APIRouter(tags=["ops"])
@router.get("/healthz", include_in_schema=False)
async def health() -> JSONResponse:
"""Kubernetes/Docker liveness probe. No auth. No versioning."""
return JSONResponse({"status": "ok"})
@router.get("/readyz", include_in_schema=False)
async def readiness(request: Request) -> JSONResponse:
"""Readiness probe — check DB connectivity, etc."""
# Example: await request.app.state.db.execute("SELECT 1")
return JSONResponse({"status": "ready"})
```
Rules:
- No authentication required on `/healthz` and `/readyz`.
- `/healthz` — liveness: can the process respond?
- `/readyz` — readiness: are dependencies available?
- Keep them on the root path (not `/api/v1/healthz`).
---
## Dependency Injection
Use `Depends()` to share resources from app state:
```python
# src/my_app/api/deps.py
from fastapi import Depends, Request
from my_app.config import Settings
def get_settings(request: Request) -> Settings:
return request.app.state.settings
SettingsDep = Annotated[Settings, Depends(get_settings)]
```
In route handlers:
```python
@router.get("/config")
async def show_config(settings: SettingsDep) -> dict:
return {"debug": settings.debug}
```
---
## Error Handling
Register a global exception handler for unhandled errors:
```python
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
# Log the exception — never expose internal details to clients
logger.exception("Unhandled error", exc_info=exc)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
```
---
## CORS
Add CORS middleware only when needed (e.g., browser clients from a different origin):
```python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins, # list from config, never ["*"] in prod
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
```
---
## Response Models
Always declare `response_model` or return type annotations — FastAPI uses them for OpenAPI docs and response validation:
```python
@router.post("/items/", response_model=ItemOut, status_code=201)
async def create_item(item: ItemIn) -> ItemOut:
...
```
Use separate `In` / `Out` models when the write shape differs from the read shape (e.g., password hashing, computed fields).
---
## Security Checklist
- Never expose `docs_url` in production (set `docs_url=None` when `not settings.debug`).
- Validate all user input with Pydantic models — never pass raw request data to DB queries.
- Use `SecretStr` for passwords and API keys in `Settings`.
- Apply authentication globally via middleware or `app.include_router(..., dependencies=[Depends(verify_token)])`.
- Add `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` headers in production middleware.
@@ -0,0 +1,251 @@
# uv Project Layout and Dependency Management
Source: https://docs.astral.sh/uv/guides/projects/ | https://docs.astral.sh/uv/concepts/projects/layout/ | https://docs.astral.sh/uv/guides/integration/docker/
---
## Core Files
| File | Purpose | Commit? |
|------|---------|---------|
| `pyproject.toml` | Project metadata, deps, tool config | Yes |
| `uv.lock` | Exact resolved versions, cross-platform | **Yes** |
| `.python-version` | Default Python version for the project | Yes |
| `.venv/` | Local virtual environment | No (`.gitignore`) |
**`uv.lock` must be committed.** It is the source of truth for reproducible installs in CI and Docker. Never edit it by hand.
---
## Essential Commands
```bash
# Initialize a new project (app, not library)
uv init --app
# Initialize a library (installable package with src layout)
uv init --lib
# Add a runtime dependency
uv add fastapi[standard]
# Add multiple dependencies at once
uv add uvicorn[standard] pydantic-settings
# Add dev-only dependency
uv add --dev pytest httpx pytest-asyncio ruff mypy
# Remove a dependency
uv remove requests
# Upgrade a specific package (keeps rest of lockfile intact)
uv lock --upgrade-package fastapi
# Upgrade all packages
uv lock --upgrade
# Sync env to lockfile (install/remove as needed)
uv sync
# Sync without dev deps (e.g., in CI or Docker)
uv sync --no-dev
# Sync and assert lockfile is up-to-date (for CI / Docker)
uv sync --locked
# Run a command in the project environment
uv run pytest
uv run uvicorn my_app.main:app --reload
# Run a one-off command without installing anything permanently
uv run --with httpx python -c "import httpx; print(httpx.__version__)"
```
---
## pyproject.toml Reference
```toml
[project]
name = "my-app"
version = "0.1.0"
description = "Production FastAPI service"
requires-python = ">=3.12"
license = { text = "MIT" }
readme = "README.md"
dependencies = [
"fastapi[standard]>=0.115",
"uvicorn[standard]>=0.34",
"pydantic-settings>=2.0",
]
[project.scripts]
# Creates `my-app` CLI entry point when installed
my-app = "my_app.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/my_app"]
[tool.uv]
dev-dependencies = [
"pytest>=8",
"pytest-asyncio>=0.24",
"httpx>=0.27", # needed for FastAPI TestClient
"ruff>=0.6",
"mypy>=1.11",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
strict-markers = true
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "S"]
[tool.mypy]
strict = true
```
---
## src Layout with uv
Use `uv init --lib` or set up manually:
```
my-app/
├── pyproject.toml
├── uv.lock
├── .python-version # e.g., "3.12"
├── .env.example
├── README.md
├── src/
│ └── my_app/
│ └── __init__.py
└── tests/
└── conftest.py
```
The `src/` layout prevents the local directory from shadowing the installed package, which would otherwise cause silent test failures when testing the installed version.
**hatchling config for src layout:**
```toml
[tool.hatch.build.targets.wheel]
packages = ["src/my_app"]
```
---
## Migration from pip / requirements.txt
```bash
# 1. Initialize uv in an existing project
uv init --no-workspace # if already has pyproject.toml, skip this
# 2. Import from requirements.txt
uv add -r requirements.txt
# 3. Import dev requirements
uv add --dev -r requirements-dev.txt
# 4. Verify lockfile was created
cat uv.lock | head -20
# 5. Clean up old files
rm requirements.txt requirements-dev.txt setup.py setup.cfg Pipfile Pipfile.lock
# 6. Add .venv to .gitignore
echo ".venv/" >> .gitignore
```
---
## uv in Docker
The canonical pattern uses `--mount=type=cache` for fast rebuilds and `--no-install-project` for layer separation:
```dockerfile
# Copy uv binary (pin the version)
COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /uvx /bin/
WORKDIR /app
# Layer 1: install dependencies (changes rarely → cached aggressively)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-editable
# Layer 2: copy source and install project (changes frequently)
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable
```
**Key flags:**
| Flag | Effect |
|------|--------|
| `--locked` | Fail if `uv.lock` is out of date with `pyproject.toml` |
| `--no-install-project` | Install deps but not the project itself (layer separation) |
| `--no-editable` | Install in non-editable mode (copy code into `.venv`, not symlink) |
| `--no-dev` | Skip dev dependencies (use in production images) |
| `--compile-bytecode` | Pre-compile `.pyc` files (faster startup, larger image) |
**After syncing, activate the venv via PATH (not `uv run`) in production:**
```dockerfile
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "my_app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Using `CMD ["uv", "run", "uvicorn", ...]` in production is fine but adds a small overhead and requires uv to be present in the final image.
---
## CI/CD with uv
```yaml
# GitHub Actions example
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "0.5.27" # pin for reproducibility
- name: Sync dependencies
run: uv sync --locked
- name: Run tests
run: uv run pytest --tb=short
- name: Lint
run: uv run ruff check .
- name: Type check
run: uv run mypy src/
```
---
## .gitignore Entries for uv Projects
```
.venv/
__pycache__/
*.pyc
.env
dist/
*.egg-info/
.mypy_cache/
.ruff_cache/
.pytest_cache/
```
**Do NOT ignore `uv.lock`** — it must be committed.