14 KiB
name, description, argument-hint
| name | description | argument-hint |
|---|---|---|
| fastapi-uv-docker | 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. | 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.txttouv - 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.ymlfor local dev and CI
Progressive Loading References
Load these references only when needed:
- FastAPI patterns and app structure: ./references/fastapi-best-practices.md
- uv project layout and dependency management: ./references/uv-project-layout.md
- uvicorn CLI settings reference: ./references/uvicorn-settings.md
- Docker and cloud-native patterns: ./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 for structure rules. Load ./references/uv-project-layout.md for uv migration rules. Load ./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:
- Initialize uv if not present:
uv init(oruv init --libfor importable package). - Import existing requirements:
uv add -r requirements.txt. - Remove
requirements.txt,setup.py,setup.cfg, and anyPipfile. - Ensure
.python-versionis committed with the target Python version. - Commit
uv.lock— it is the source of truth for reproducible installs. - Add
.venvto.gitignoreand.dockerignore.
Canonical pyproject.toml shape:
[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:
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 for the full patterns. Key rules:
src/my_app/main.py:
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:
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:
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 for the full settings reference.
Never configure uvicorn inside application code. Pass all settings via CLI or environment variables (UVICORN_* prefix).
# 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):
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 for the full template and cloud-native rules. Key requirements:
- Multi-stage build:
builderstage installs deps;runtimestage is slim. - Pin uv version (copy from official image, not
latest). - Use
uv sync --locked --no-editableto install into.venv. - Set
ENV PATH="/app/.venv/bin:$PATH"— do not useuv runin productionCMD. - Run as non-root user.
- Use
CMDexec form, never shell form. - Add
HEALTHCHECK.
Canonical 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):
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 /healthzreturns 200 with no auth required- App reads all config from environment (
Settingswith no hardcoded values) .envis in.gitignoreand.dockerignore;.env.exampleis committeduv.lockis committed.python-versionis committed- Dockerfile uses non-root user (
USER appuser) CMDuses exec form (list, not string)--proxy-headersis set in the uvicornCMDif behind a proxyPYTHONUNBUFFERED=1is set (logs flush immediately)EXPOSEdeclares the correct portHEALTHCHECKis defined- Multi-stage build — final image contains no build tools or uv binary
.venvis in.dockerignore- No secrets hardcoded in
Dockerfile,pyproject.toml, or source uv sync --lockedin 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 |