Files
prompts/skills/fastapi-uv-docker/SKILL.md
T
John Lancaster 6e32955533 uvicorn settings
2026-06-17 00:28:59 -05:00

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


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:

  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:

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

# 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 /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