FastAPI template with async PostgreSQL, cookie-based JWT authentication, refresh tokens, and security hardening.
| Component | Technology |
|---|---|
| Framework | FastAPI |
| Database | PostgreSQL (async via asyncpg) |
| ORM | SQLAlchemy 2.0 |
| Migrations | Alembic |
| Auth | FastAPI-Users (cookie JWT) |
| Rate Limiting | slowapi |
| Package Manager | uv |
| Containerization | Docker / Docker Compose |
| Testing | Pytest (async) |
| Linting/Formatting | Ruff |
| Git Hooks | pre-commit |
- Python 3.12+
- uv
- Docker & Docker Compose (for local development)
# Clone and enter directory
cd api-template
# Copy environment file
cp .env.example .env
# Install dependencies
uv sync
# Start PostgreSQL
docker compose up -d db
# Run migrations
uv run alembic upgrade head
# Start the API
uv run uvicorn app.main:app --reloadThe API will be available at http://localhost:8000
# Start everything (API + PostgreSQL + Adminer)
docker compose up
# Or run in background
docker compose up -dOnce running, visit:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
- OpenAPI JSON: http://localhost:8000/openapi.json
Adminer is available at http://localhost:8080 when running via Docker Compose.
Login: System=PostgreSQL, Server=db, User=postgres, Password=postgres, Database=api_template
This API automatically generates an OpenAPI specification that can be used to generate type-safe clients for frontends. The companion web-template uses orval to generate React Query hooks and TypeScript types from this spec.
To generate the frontend client:
# In the frontend project run this or any similar applicable command
pnpm generate-apiThis requires the API to be running locally (or set OPENAPI_URL to point to a deployed instance).
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/register |
Create a new account |
| POST | /auth/jwt/login |
Log in (sets access + refresh cookies) |
| POST | /auth/jwt/logout |
Log out (revokes tokens, clears cookies) |
| POST | /auth/refresh |
Rotate refresh token, reissue access token |
| GET | /auth/me |
Get current authenticated user |
Admin endpoints require the admin role (superusers also have access).
| Method | Endpoint | Description |
|---|---|---|
| PATCH | /admin/users/{id}/role |
Update a user's role |
All note endpoints require authentication. Users can only access their own notes.
| Method | Endpoint | Description |
|---|---|---|
| GET | /notes |
List current user's notes |
| GET | /notes/{id} |
Get a note by ID |
| POST | /notes |
Create a note |
| PATCH | /notes/{id} |
Update a note |
| DELETE | /notes/{id} |
Delete a note |
Authentication uses httpOnly cookies with short-lived access tokens and rotating refresh tokens.
- Access token: 15-minute JWT stored in an
app_accesshttpOnly cookie - Refresh token: 7-day JWT stored in an
app_refreshhttpOnly cookie (scoped to/auth/refresh) - Token rotation: Each refresh issues a new token in the same family; reuse of an old token revokes the entire family (theft detection)
- Rate limiting: Login (5/min), registration (3/min), refresh (30/min)
Users have a role field (default: user). Roles are defined as a StrEnum in app/auth/roles.py:
- user — default role for all registered users
- admin — can access admin endpoints (e.g. updating user roles)
Superusers (is_superuser=True) bypass all role checks. Roles are read-only via GET /auth/me and can only be changed by admins via PATCH /admin/users/{id}/role. The require_role() dependency factory can be used to gate any route:
from app.auth import require_role
@router.get("/admin-only")
async def admin_only(user: User = Depends(require_role("admin"))): ...- Cookie auth: httpOnly, Secure (in production), SameSite
- CORS lockdown: Explicit origins, methods, and headers (no wildcards in production)
- Security headers: HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy, Permissions-Policy
- Rate limiting: Per-endpoint, per-client-IP limits on auth routes with security event logging (client IPs are resolved behind Cloud Run's proxy; see Client IPs behind Cloud Run)
- Production config validation: Rejects weak secrets and default database credentials at startup
- Security event logging: Structured logs for login, logout, registration, token refresh, and rate limit events
Logging uses structlog for structured output. In development you get colored console logs; in production, JSON.
Every request is assigned a unique X-Request-ID header (or reuses one from the incoming request), and it's automatically bound to all log entries for that request.
Configure via LOG_LEVEL env var (default: INFO).
OpenTelemetry tracing is included but disabled by default. To enable, set OTEL_ENABLED=true and point OTEL_EXPORTER_ENDPOINT at your collector (e.g. Jaeger, Grafana Tempo). FastAPI is auto-instrumented — no code changes needed.
app/analytics.py provides an AnalyticsBackend protocol with track() and identify() methods. The default LogAnalyticsBackend writes events to structlog. Swap it out by replacing the analytics module-level instance with your own implementation (e.g. Segment, PostHog).
Use the get_analytics() FastAPI dependency to access it in route handlers.
Feature flags are read from FEATURE_* environment variables at startup (no database required). Set FEATURE_<NAME>=true or false in your .env.
The GET /flags endpoint (requires authentication) returns all flags as a JSON object, consumed by the web-template's FeatureFlagProvider.
Use the get_feature_flags() dependency in route handlers to check flags server-side via flags.is_enabled("flag_name").
This project uses Alembic for database migrations.
- Edit a model in
app/models/ - Generate a migration:
uv run alembic revision --autogenerate -m "description of change" - Review the generated file in
alembic/versions/(autogenerate can miss some changes) - Apply the migration:
uv run alembic upgrade head
- Commit both the model change and migration file
# Apply all pending migrations
uv run alembic upgrade head
# Rollback one migration
uv run alembic downgrade -1
# See current migration status
uv run alembic current
# See migration history
uv run alembic history
# Generate migration without applying
uv run alembic revision --autogenerate -m "description"The API runs on Cloud Run in GCP project treepolitics-prod (region us-east1), backed by a Cloud SQL Postgres instance and Secret Manager.
Merges to main auto-deploy (see Continuous deployment below); use this manual path only for out-of-band pushes. From a clean working tree on main (set GCP_ACCOUNT to your gcloud deploy account):
GCP_ACCOUNT=you@example.com ./scripts/deploy.shThe script builds via Cloud Build, pushes to Artifact Registry tagged with the current git short-SHA, and deploys a new Cloud Run revision pinned to that image.
Migrations are not auto-applied on container start. Run them separately before deploying a revision that introduces schema changes:
# Terminal 1 — start the Cloud SQL Auth Proxy
cloud-sql-proxy --address 127.0.0.1 --port 5432 \
treepolitics-prod:us-east1:treepolitics-db
# Terminal 2 — run migrations against the proxy
DATABASE_URL="postgresql+asyncpg://app:<password>@127.0.0.1:5432/treepolitics" \
uv run alembic upgrade headThe DB user password is in Secret Manager (not committed).
- Cloud SQL Postgres 16 (
treepolitics-db,db-f1-micro, zonal, us-east1) — public IP, no authorized networks; only reachable via the Cloud SQL connector. - Artifact Registry docker repo
apiin us-east1. - Runtime SA
treepolitics-api-runtime@treepolitics-prod.iam.gserviceaccount.comwithroles/cloudsql.clientandroles/secretmanager.secretAccessor(scoped toDATABASE_URLandSECRET_KEY). - Secrets in Secret Manager:
DATABASE_URL,SECRET_KEY. - Custom domain
api.treepolitics.netvia Cloud Run domain mapping.
Cloud Run connects to the container from a link-local address (169.254.x.x) and passes the caller's IP in X-Forwarded-For. start.sh runs uvicorn with --forwarded-allow-ips 169.254.0.0/16 (override with FORWARDED_ALLOW_IPS), so request.client — which rate limiting and security logs use — is the real caller. uvicorn takes the right-most untrusted entry, the one Cloud Run appended, so callers can't spoof it by sending their own header. Without this, every request appears to come from the proxy and all callers share one set of rate limits.
Every merge to main auto-deploys to Cloud Run via GitHub Actions (.github/workflows/deploy.yml), so scripts/deploy.sh is only needed for out-of-band pushes.
- Auth — Workload Identity Federation, no stored keys. The workflow mints a short-lived OIDC token that impersonates
github-deployer@treepolitics-prod.iam.gserviceaccount.com(poolgithub-pool, providergithub-provider, locked to this repo). - Build on the runner, not Cloud Build. The image is built with
docker buildon the runner anddocker pushed to Artifact Registry. Cloud Build is intentionally avoided here: its log streaming requires a project-level Viewer role that the scoped deploy SA deliberately lacks. - Deploy.
gcloud run deploywith the same flags asscripts/deploy.sh. - Migrations are not run in CI. Apply them first (see Run migrations against prod) before merging a schema-changing revision.
Tests use SQLite in-memory for speed and isolation.
# Run all tests
uv run pytest
# Run with verbose output
uv run pytest -v
# Run specific test file
uv run pytest tests/test_notes.py
# Run with coverage
uv run pytest --cov=appThe test harness provides test_user and other_user fixtures for testing user isolation, and an auth_client fixture that provides an authenticated HTTP client.
This project uses Ruff for linting and formatting.
# Check for linting errors
uv run ruff check .
# Fix auto-fixable errors
uv run ruff check --fix .
# Format code
uv run ruff format .
# Check formatting without changes
uv run ruff format --check .Initialize git and install pre-commit hooks to auto-format on commit:
# Initialize git repository
git init
# Install pre-commit hooks
uv run pre-commit install
# Run hooks manually on all files
uv run pre-commit run --all-filesOnce installed, ruff will automatically check and format your code before each commit.
api-template/
├── app/
│ ├── auth/
│ │ ├── backend.py # Cookie transport + JWT strategy
│ │ ├── refresh.py # Refresh token create/rotate/revoke
│ │ ├── roles.py # UserRole enum + require_role() dependency
│ │ ├── security_logging.py # Structured security event logging
│ │ └── users.py # UserManager with login/failure hooks
│ ├── models/
│ │ ├── note.py # Note model (example CRUD entity)
│ │ ├── refresh_token.py # Refresh token model
│ │ └── user.py # User model (FastAPI-Users)
│ ├── routers/
│ │ ├── admin.py # Admin endpoints (role management)
│ │ ├── auth_refresh.py # /auth/refresh and /auth/jwt/logout
│ │ └── notes.py # Notes CRUD (user-scoped)
│ ├── schemas/
│ │ ├── note.py # Note request/response schemas
│ │ └── user.py # User schemas (FastAPI-Users)
│ ├── analytics.py # Analytics event abstraction
│ ├── config.py # Settings with production validation
│ ├── database.py # Async SQLAlchemy setup
│ ├── features.py # Feature flags (env-var backed)
│ ├── logging.py # Structlog configuration
│ ├── telemetry.py # OpenTelemetry setup
│ └── main.py # App entry point, middleware, routes
├── alembic/
│ ├── versions/ # Migration files
│ └── env.py # Alembic configuration
├── tests/
│ ├── conftest.py # Fixtures (client, session, users)
│ ├── test_notes.py # Notes CRUD + isolation tests
│ └── test_roles.py # Role-based access control tests
├── .env.example # Environment template
├── .pre-commit-config.yaml
├── .python-version # pyenv Python version
├── docker-compose.yml # API + PostgreSQL + Adminer
├── Dockerfile
└── pyproject.toml
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | postgresql+asyncpg://postgres:postgres@localhost:5432/api_template |
SECRET_KEY |
JWT signing key (min 32 chars in production) | change-me-in-production |
ENVIRONMENT |
development or production |
development |
CORS_ORIGINS |
Comma-separated allowed origins (production) | (empty — dev uses localhost:5100-5199) |
FRONTEND_URL |
Frontend URL for redirects | http://localhost:5173 |
COOKIE_DOMAIN |
Cookie domain (leave empty for localhost) | (empty) |
LOG_LEVEL |
Logging level | INFO |
OTEL_ENABLED |
Enable OpenTelemetry tracing | false |
OTEL_SERVICE_NAME |
Service name for traces | api-template |
OTEL_EXPORTER_ENDPOINT |
OTLP gRPC collector endpoint | http://localhost:4317 |
FEATURE_* |
Feature flags (e.g. FEATURE_NEW_DASHBOARD=true) |
(none) |
This project is licensed under the Apache 2.0 License - see the LICENSE file for details.