Compare commits
2 Commits
57da7e001e
...
6e32955533
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e32955533 | |||
| 78fa9235d3 |
@@ -0,0 +1,425 @@
|
|||||||
|
---
|
||||||
|
name: fastapi-uv-docker
|
||||||
|
description: 'Audit and migrate an existing Python project to best practices for a cloud-native ASGI FastAPI app managed with uv and run with uvicorn in Docker. Use when: conforming a project to production standards, setting up src layout, configuring pyproject.toml, writing multi-stage Dockerfiles, wiring lifespan and settings, adding health endpoints, enforcing non-root container user, migrating from requirements.txt to uv.'
|
||||||
|
argument-hint: 'What is the current state of the project (bare Python, requirements.txt, pip, etc.)?'
|
||||||
|
---
|
||||||
|
|
||||||
|
# FastAPI Project Best Practices
|
||||||
|
|
||||||
|
Bring an existing Python project into full conformance with cloud-native best practices: **FastAPI + uv + uvicorn + Docker**. This skill audits the current state, produces a gap list, and walks through each conformance area in priority order.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Migrating an existing app from `pip`/`requirements.txt` to `uv`
|
||||||
|
- Conforming a FastAPI app to src-layout and app-factory structure
|
||||||
|
- Writing a production-grade Dockerfile with multi-stage builds
|
||||||
|
- Wiring Pydantic settings from environment variables
|
||||||
|
- Adding health endpoints and graceful lifespan shutdown
|
||||||
|
- Enforcing non-root container user and proper signal handling
|
||||||
|
- Setting up `docker-compose.yml` for local dev and CI
|
||||||
|
|
||||||
|
## Progressive Loading References
|
||||||
|
|
||||||
|
Load these references only when needed:
|
||||||
|
|
||||||
|
- FastAPI patterns and app structure: [./references/fastapi-best-practices.md](./references/fastapi-best-practices.md)
|
||||||
|
- uv project layout and dependency management: [./references/uv-project-layout.md](./references/uv-project-layout.md)
|
||||||
|
- uvicorn CLI settings reference: [./references/uvicorn-settings.md](./references/uvicorn-settings.md)
|
||||||
|
- Docker and cloud-native patterns: [./references/docker-cloud-native.md](./references/docker-cloud-native.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### Step 0: Audit the Project
|
||||||
|
|
||||||
|
Before making changes, map the current state across six areas. Produce a short gap list for each.
|
||||||
|
|
||||||
|
| Area | Check |
|
||||||
|
|------|-------|
|
||||||
|
| **Project manager** | Is `uv` used? Is `pyproject.toml` present? Is `uv.lock` committed? |
|
||||||
|
| **Package layout** | Is a `src/` layout used? Is the package installable? |
|
||||||
|
| **App structure** | Is `create_app()` factory used? Is lifespan wired? Are routers registered via `APIRouter`? |
|
||||||
|
| **Configuration** | Are settings loaded from env via Pydantic `BaseSettings`? Are secrets out of code? |
|
||||||
|
| **Container** | Is there a `Dockerfile`? Multi-stage? Non-root user? `.dockerignore` present? |
|
||||||
|
| **Cloud-native** | Is there a `/healthz` endpoint? Graceful shutdown? Structured logs? |
|
||||||
|
|
||||||
|
Load [./references/fastapi-best-practices.md](./references/fastapi-best-practices.md) for structure rules.
|
||||||
|
Load [./references/uv-project-layout.md](./references/uv-project-layout.md) for uv migration rules.
|
||||||
|
Load [./references/uvicorn-settings.md](./references/uvicorn-settings.md) for uvicorn CLI reference.
|
||||||
|
|
||||||
|
Completion check: You can name every gap before touching any file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1: Migrate to uv and Establish pyproject.toml
|
||||||
|
|
||||||
|
**If the project uses `requirements.txt` / `setup.py` / `setup.cfg` / `pip`:**
|
||||||
|
|
||||||
|
1. Initialize uv if not present: `uv init` (or `uv init --lib` for importable package).
|
||||||
|
2. Import existing requirements: `uv add -r requirements.txt`.
|
||||||
|
3. Remove `requirements.txt`, `setup.py`, `setup.cfg`, and any `Pipfile`.
|
||||||
|
4. Ensure `.python-version` is committed with the target Python version.
|
||||||
|
5. Commit `uv.lock` — it is the source of truth for reproducible installs.
|
||||||
|
6. Add `.venv` to `.gitignore` and `.dockerignore`.
|
||||||
|
|
||||||
|
**Canonical `pyproject.toml` shape:**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "my-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi[standard]>=0.115",
|
||||||
|
"uvicorn[standard]>=0.34",
|
||||||
|
"pydantic-settings>=2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
my-app = "my_app.main:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
dev-dependencies = [
|
||||||
|
"pytest>=8",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/my_app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add fastapi[standard] uvicorn[standard] pydantic-settings
|
||||||
|
uv add --dev pytest httpx pytest-asyncio
|
||||||
|
uv sync # creates .venv and installs all deps
|
||||||
|
uv run pytest # run tests via uv
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion check: `uv run python -c "import my_app"` succeeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Establish src Layout
|
||||||
|
|
||||||
|
Move the package under `src/` to prevent import confusion between installed and local code.
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── pyproject.toml
|
||||||
|
├── uv.lock
|
||||||
|
├── .python-version
|
||||||
|
├── .env.example
|
||||||
|
├── README.md
|
||||||
|
├── src/
|
||||||
|
│ └── my_app/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py # create_app() + entry point
|
||||||
|
│ ├── config.py # Pydantic BaseSettings
|
||||||
|
│ ├── lifespan.py # @asynccontextmanager lifespan
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── health.py # GET /healthz
|
||||||
|
│ │ └── v1/
|
||||||
|
│ │ └── __init__.py
|
||||||
|
│ └── services/
|
||||||
|
│ └── __init__.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── conftest.py
|
||||||
|
│ └── test_health.py
|
||||||
|
├── Dockerfile
|
||||||
|
├── .dockerignore
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion check: `uv run python -m my_app` starts the server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Wire the FastAPI App Factory
|
||||||
|
|
||||||
|
Load [./references/fastapi-best-practices.md](./references/fastapi-best-practices.md) for the full patterns. Key rules:
|
||||||
|
|
||||||
|
**`src/my_app/main.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from my_app.api.health import router as health_router
|
||||||
|
from my_app.config import Settings
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
|
if settings is None:
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# startup: open DB pools, load models, etc.
|
||||||
|
app.state.settings = settings
|
||||||
|
yield
|
||||||
|
# shutdown: close connections
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="My App",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs" if settings.debug else None,
|
||||||
|
redoc_url=None,
|
||||||
|
)
|
||||||
|
app.include_router(health_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
```
|
||||||
|
|
||||||
|
**`src/my_app/api/health.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["ops"])
|
||||||
|
|
||||||
|
@router.get("/healthz", include_in_schema=False)
|
||||||
|
async def health() -> JSONResponse:
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
```
|
||||||
|
|
||||||
|
**`src/my_app/config.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
debug: bool = False
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
log_level: str = "info"
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion check: `uv run uvicorn my_app.main:app --reload` starts with no import errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: uvicorn Production Configuration
|
||||||
|
|
||||||
|
Load [./references/uvicorn-settings.md](./references/uvicorn-settings.md) for the full settings reference.
|
||||||
|
|
||||||
|
**Never** configure uvicorn inside application code. Pass all settings via CLI or environment variables (`UVICORN_*` prefix).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development — reload only; never in production
|
||||||
|
uv run uvicorn my_app.main:app \
|
||||||
|
--reload \
|
||||||
|
--host 127.0.0.1 \
|
||||||
|
--port 8000 \
|
||||||
|
--log-level debug
|
||||||
|
|
||||||
|
# Production — single process (orchestrator handles replication)
|
||||||
|
uv run uvicorn my_app.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8000 \
|
||||||
|
--workers 1 \
|
||||||
|
--loop auto \
|
||||||
|
--http auto \
|
||||||
|
--log-level info \
|
||||||
|
--proxy-headers \
|
||||||
|
--forwarded-allow-ips '*' \
|
||||||
|
--timeout-graceful-shutdown 30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key flags for production:**
|
||||||
|
|
||||||
|
| Flag | Value | Reason |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `--host 0.0.0.0` | Required in containers | Bind to all interfaces, not just loopback |
|
||||||
|
| `--workers 1` | Kubernetes/Cloud Run | Orchestrator replicates containers |
|
||||||
|
| `--loop auto` | Default | Uses `uvloop` when available (install `uvicorn[standard]`) |
|
||||||
|
| `--http auto` | Default | Uses `httptools` when available |
|
||||||
|
| `--proxy-headers` | Behind any proxy | Trusts `X-Forwarded-For`, `X-Forwarded-Proto` |
|
||||||
|
| `--forwarded-allow-ips '*'` | Container/K8s | Trusts proxy headers from all IPs (safe when inside a trusted network) |
|
||||||
|
| `--timeout-graceful-shutdown 30` | Prod | Seconds to wait before force-closing requests on shutdown |
|
||||||
|
| `--no-access-log` | High-traffic prod | Disable per-request logs if using structured app-level logging |
|
||||||
|
|
||||||
|
**Environment variable equivalents** (useful in `docker-compose.yml` / K8s manifests):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UVICORN_HOST=0.0.0.0
|
||||||
|
UVICORN_PORT=8000
|
||||||
|
UVICORN_WORKERS=1
|
||||||
|
UVICORN_LOG_LEVEL=info
|
||||||
|
UVICORN_PROXY_HEADERS=true
|
||||||
|
UVICORN_FORWARDED_ALLOW_IPS=*
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--reload` and `--workers` are mutually exclusive** — never combine them.
|
||||||
|
|
||||||
|
**When to use `--workers > 1`:** Only for Docker Compose on a single host where orchestrator-level replication is not available. For Kubernetes / Cloud Run / Fargate: always `--workers 1` and scale via replicas — this gives predictable per-container memory and cleaner crash isolation.
|
||||||
|
|
||||||
|
Completion check: `curl http://localhost:8000/healthz` returns `{"status":"ok"}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Write the Dockerfile
|
||||||
|
|
||||||
|
Load [./references/docker-cloud-native.md](./references/docker-cloud-native.md) for the full template and cloud-native rules. Key requirements:
|
||||||
|
|
||||||
|
- Multi-stage build: `builder` stage installs deps; `runtime` stage is slim.
|
||||||
|
- Pin uv version (copy from official image, not `latest`).
|
||||||
|
- Use `uv sync --locked --no-editable` to install into `.venv`.
|
||||||
|
- Set `ENV PATH="/app/.venv/bin:$PATH"` — do **not** use `uv run` in production `CMD`.
|
||||||
|
- Run as non-root user.
|
||||||
|
- Use `CMD` exec form, never shell form.
|
||||||
|
- Add `HEALTHCHECK`.
|
||||||
|
|
||||||
|
**Canonical Dockerfile:**
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ── builder ──────────────────────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim-bookworm AS builder
|
||||||
|
|
||||||
|
# Pin uv version for reproducibility
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /uvx /bin/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first (cache layer)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Copy source and install project
|
||||||
|
COPY . /app
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --locked --no-editable
|
||||||
|
|
||||||
|
# ── runtime ───────────────────────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim-bookworm AS runtime
|
||||||
|
|
||||||
|
# Non-root user
|
||||||
|
RUN groupadd --system --gid 1001 appgroup && \
|
||||||
|
useradd --system --uid 1001 --gid appgroup --no-log-init appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy only the virtual environment (not source code)
|
||||||
|
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
||||||
|
|
||||||
|
# Copy application source
|
||||||
|
COPY --chown=appuser:appgroup src/ /app/src/
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"
|
||||||
|
|
||||||
|
# Exec form — required for graceful shutdown / lifespan events
|
||||||
|
CMD ["uvicorn", "my_app.main:app", \
|
||||||
|
"--host", "0.0.0.0", \
|
||||||
|
"--port", "8000", \
|
||||||
|
"--workers", "1", \
|
||||||
|
"--proxy-headers"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion check: `docker build -t my-app . && docker run --rm -p 8000:8000 my-app` serves `/healthz`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6: Write .dockerignore and docker-compose.yml
|
||||||
|
|
||||||
|
**`.dockerignore`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
.venv/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**`docker-compose.yml` (local dev):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DEBUG=true
|
||||||
|
- LOG_LEVEL=debug
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: ./src
|
||||||
|
target: /app/src
|
||||||
|
- action: rebuild
|
||||||
|
path: ./pyproject.toml
|
||||||
|
- action: rebuild
|
||||||
|
path: ./uv.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion check: `docker compose up` starts the app; `docker compose watch` enables hot reload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7: Cloud-Native Checklist
|
||||||
|
|
||||||
|
Run this final checklist before shipping:
|
||||||
|
|
||||||
|
- [ ] `GET /healthz` returns 200 with no auth required
|
||||||
|
- [ ] App reads all config from environment (`Settings` with no hardcoded values)
|
||||||
|
- [ ] `.env` is in `.gitignore` and `.dockerignore`; `.env.example` is committed
|
||||||
|
- [ ] `uv.lock` is committed
|
||||||
|
- [ ] `.python-version` is committed
|
||||||
|
- [ ] Dockerfile uses non-root user (`USER appuser`)
|
||||||
|
- [ ] `CMD` uses exec form (list, not string)
|
||||||
|
- [ ] `--proxy-headers` is set in the uvicorn `CMD` if behind a proxy
|
||||||
|
- [ ] `PYTHONUNBUFFERED=1` is set (logs flush immediately)
|
||||||
|
- [ ] `EXPOSE` declares the correct port
|
||||||
|
- [ ] `HEALTHCHECK` is defined
|
||||||
|
- [ ] Multi-stage build — final image contains no build tools or uv binary
|
||||||
|
- [ ] `.venv` is in `.dockerignore`
|
||||||
|
- [ ] No secrets hardcoded in `Dockerfile`, `pyproject.toml`, or source
|
||||||
|
- [ ] `uv sync --locked` in CI (fail if lock is stale)
|
||||||
|
- [ ] Tests pass via `uv run pytest`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-patterns to Fix
|
||||||
|
|
||||||
|
| Anti-pattern | Correct approach |
|
||||||
|
|---|---|
|
||||||
|
| `requirements.txt` | Use `uv` with `pyproject.toml` and `uv.lock` |
|
||||||
|
| `pip install` in Dockerfile | `uv sync --locked` |
|
||||||
|
| `ENV SECRET_KEY=abc123` in Dockerfile | Inject at runtime via env; never bake secrets |
|
||||||
|
| Shell form `CMD uvicorn ...` | Exec form `CMD ["uvicorn", ...]` |
|
||||||
|
| `FROM tiangolo/uvicorn-gunicorn-fastapi` | Build from scratch with `python:3.x-slim` |
|
||||||
|
| Multiple workers inside K8s container | `--workers 1`; scale via replicas |
|
||||||
|
| Running as root in container | `USER appuser` with explicit UID 1001 |
|
||||||
|
| Startup/shutdown in `@app.on_event` | Use `@asynccontextmanager` lifespan |
|
||||||
|
| Config loaded from `.env` directly in code | Pydantic `BaseSettings` with `env_file` |
|
||||||
@@ -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/ | https://uvicorn.dev/deployment/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 150–200 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.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# uvicorn Settings Reference
|
||||||
|
|
||||||
|
Source: https://uvicorn.dev/settings/ | https://uvicorn.dev/deployment/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Methods
|
||||||
|
|
||||||
|
Three equivalent approaches (CLI takes precedence over env vars):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. CLI flags
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# 2. UVICORN_* environment variables
|
||||||
|
export UVICORN_HOST=0.0.0.0
|
||||||
|
export UVICORN_PORT=8000
|
||||||
|
uvicorn main:app
|
||||||
|
|
||||||
|
# 3. Programmatic (dev/test only)
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `UVICORN_*` env vars cannot be used from within an `--env-file`. The `--env-file` flag is for the ASGI *application's* config, not uvicorn's own config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## All Settings by Category
|
||||||
|
|
||||||
|
### Socket Binding
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--host <str>` | `127.0.0.1` | Use `0.0.0.0` in containers to bind all interfaces |
|
||||||
|
| `--port <int>` | `8000` | Use `0` to auto-pick an available port |
|
||||||
|
| `--uds <path>` | — | UNIX domain socket path (use behind Nginx) |
|
||||||
|
| `--fd <int>` | — | Inherit socket from file descriptor (use with Supervisor) |
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--workers <int>` | `1` (or `$WEB_CONCURRENCY`) | **Mutually exclusive with `--reload`** |
|
||||||
|
| `--env-file <path>` | — | Env file for the *application* (not uvicorn itself) |
|
||||||
|
| `--timeout-worker-healthcheck <int>` | `5` | Seconds before killing a stuck worker |
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--log-level <str>` | `info` | `critical`, `error`, `warning`, `info`, `debug`, `trace` |
|
||||||
|
| `--log-config <path>` | — | `.json` or `.yaml` for `dictConfig()`; other formats use `fileConfig()` |
|
||||||
|
| `--no-access-log` | — | Disable access log without changing log level |
|
||||||
|
| `--use-colors / --no-use-colors` | auto | Force color on/off in log output |
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--loop <str>` | `auto` | `auto`, `asyncio`, `uvloop`. `uvloop` requires `uvicorn[standard]` |
|
||||||
|
| `--http <str>` | `auto` | `auto`, `h11`, `httptools`. `httptools` requires `uvicorn[standard]` |
|
||||||
|
| `--ws <str>` | `auto` | `auto`, `none`, `websockets`, `websockets-sansio`, `wsproto` |
|
||||||
|
| `--lifespan <str>` | `auto` | `auto`, `on`, `off` |
|
||||||
|
| `--ws-max-size <int>` | `16777216` | WebSocket max message size in bytes (16 MB) |
|
||||||
|
| `--ws-ping-interval <float>` | `20.0` | WebSocket ping interval in seconds |
|
||||||
|
| `--ws-ping-timeout <float>` | `20.0` | WebSocket ping timeout in seconds |
|
||||||
|
|
||||||
|
### HTTP / Proxy Headers
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--proxy-headers` | enabled | Trust `X-Forwarded-For`, `X-Forwarded-Proto` from trusted IPs |
|
||||||
|
| `--no-proxy-headers` | — | Disable proxy header trust entirely |
|
||||||
|
| `--forwarded-allow-ips <list>` | `127.0.0.1` | Comma-separated IPs/networks/literals to trust. Use `'*'` to trust all (safe in containers behind a trusted LB). **Security risk if exposed directly to internet.** |
|
||||||
|
| `--root-path <str>` | `""` | ASGI `root_path` for apps mounted below a URL prefix |
|
||||||
|
| `--server-header / --no-server-header` | enabled | Include/suppress `Server` response header |
|
||||||
|
| `--date-header / --no-date-header` | enabled | Include/suppress `Date` response header |
|
||||||
|
| `--header <name:value>` | — | Add custom default response headers (repeatable) |
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--limit-concurrency <int>` | — | Max concurrent connections/tasks; returns HTTP 503 above this |
|
||||||
|
| `--limit-max-requests <int>` | — | Restart worker after N requests (limits memory leak accumulation) |
|
||||||
|
| `--limit-max-requests-jitter <int>` | `0` | Random jitter added to `--limit-max-requests` to stagger worker restarts |
|
||||||
|
| `--backlog <int>` | `2048` | Max queued connections under high load |
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--timeout-keep-alive <int>` | `5` | Close keep-alive connections after N seconds of inactivity |
|
||||||
|
| `--timeout-graceful-shutdown <int>` | — | Seconds to wait for in-flight requests to complete on SIGTERM before force-closing |
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--reload` | `False` | Auto-reload on file changes. **Never use in production.** Mutually exclusive with `--workers`. |
|
||||||
|
| `--reload-dir <path>` | `.` | Directory to watch for changes (repeatable) |
|
||||||
|
| `--reload-delay <float>` | `0.25` | Seconds between reload checks |
|
||||||
|
| `--reload-include <glob>` | `*.py` | Patterns to include in watch (requires `watchfiles`) |
|
||||||
|
| `--reload-exclude <glob>` | `.*, .py[cod], ...` | Patterns to exclude from watch (requires `watchfiles`) |
|
||||||
|
|
||||||
|
### Application
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `--factory` | — | Treat `APP` as a `() -> ASGI app` callable (app factory pattern) |
|
||||||
|
| `--app-dir <path>` | `.` | Add to `PYTHONPATH` when resolving `APP` |
|
||||||
|
| `--reset-contextvars` | `False` | Run each request in a fresh `contextvars.Context` (asyncio only; workaround for a CPython context-leak bug) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Production CMD
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn my_app.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8000 \
|
||||||
|
--workers 1 \
|
||||||
|
--loop auto \
|
||||||
|
--http auto \
|
||||||
|
--log-level info \
|
||||||
|
--proxy-headers \
|
||||||
|
--forwarded-allow-ips '*' \
|
||||||
|
--timeout-graceful-shutdown 30
|
||||||
|
```
|
||||||
|
|
||||||
|
In a Dockerfile (exec form):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
CMD ["uvicorn", "my_app.main:app",
|
||||||
|
"--host", "0.0.0.0",
|
||||||
|
"--port", "8000",
|
||||||
|
"--workers", "1",
|
||||||
|
"--proxy-headers",
|
||||||
|
"--forwarded-allow-ips", "*",
|
||||||
|
"--timeout-graceful-shutdown", "30"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Process Manager Options
|
||||||
|
|
||||||
|
### Built-in multi-worker (Docker Compose / single host)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn my_app.main:app --workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
The built-in manager spawns workers, monitors their health, and auto-restarts crashed workers. Signal support:
|
||||||
|
- `SIGHUP` — rolling graceful restart (deploy new code without dropping requests)
|
||||||
|
- `SIGTTIN` — add one worker
|
||||||
|
- `SIGTTOU` — remove one worker
|
||||||
|
|
||||||
|
### Behind Nginx (UNIX socket)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn my_app.main:app --uds /tmp/uvicorn.sock --proxy-headers
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx config headers to set:
|
||||||
|
```nginx
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `uvicorn[standard]` vs bare `uvicorn`
|
||||||
|
|
||||||
|
| Package | Extras included |
|
||||||
|
|---------|----------------|
|
||||||
|
| `uvicorn` | Pure Python h11 HTTP, asyncio event loop |
|
||||||
|
| `uvicorn[standard]` | `uvloop` (faster event loop), `httptools` (faster HTTP parser), `watchfiles` (better reload), `websockets`, `PyYAML` (for `--log-config`) |
|
||||||
|
|
||||||
|
Use `uvicorn[standard]` in all environments. The `[standard]` extras are also included when you install `fastapi[standard]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
| Anti-pattern | Fix |
|
||||||
|
|---|---|
|
||||||
|
| `--reload` in Dockerfile CMD | Remove it — `--reload` is dev-only |
|
||||||
|
| `--loop uvloop` explicitly | Use `--loop auto` — it selects uvloop automatically when available |
|
||||||
|
| `--http h11` explicitly in prod | Use `--http auto` — it selects httptools when available |
|
||||||
|
| `uvicorn.run()` at module level (no `if __name__ == '__main__':`) | Breaks multiprocessing workers; always guard it |
|
||||||
|
| Shell form `CMD uvicorn ...` | Exec form `CMD ["uvicorn", ...]` — required for SIGTERM to reach uvicorn |
|
||||||
Reference in New Issue
Block a user