6.5 KiB
FastAPI Skeleton
A production-shaped starting point for a web app that serves both HTML (HTMX) and a JSON API.
Stack
| Concern | Choice |
|---|---|
| Web framework | FastAPI |
| HTML / interactivity | Jinja2 + HTMX |
| ORM | SQLAlchemy 2.0 (async) |
| Migrations | Alembic |
| Database | SQLite (default) or Postgres — one DATABASE_URL |
| Auth | JWT access + refresh tokens, rotation, DB-backed revocation |
| Authorization | RBAC: users → roles → permissions |
| Admin UI | Custom HTMX dashboard (user & role CRUD) |
| Config | env vars or .env (pydantic-settings) |
| Tooling | uv, pytest, ruff |
| Packaging | Dockerfile + docker-compose |
Layout
app/
core/ config, database engine/session, security (hashing + JWT)
models/ SQLAlchemy models: user, role, permission, refresh_token
schemas/ Pydantic request/response models
crud/ data-access helpers
services/ auth token issuance / rotation
deps.py DI: get_db, current user, require_permission(...)
api/ JSON API: auth, users, roles
web/ HTML/HTMX routes: pages + admin dashboard
templates/ Jinja2 templates (+ admin partials)
static/ CSS
alembic/ migration environment + initial migration
scripts/seed.py bootstrap permissions, roles, first superuser
tests/ pytest (auth + RBAC)
Starting a new project
Fork or copy this repo, then update these six things before writing any code:
1. Project package name — pyproject.toml
[project]
name = "my-app" # used by uv/pip
description = "What your app does."
2. Application display name — .env / environment
The name shown in the browser title bar, nav bar, and /docs page title is
driven by APP_NAME in your .env (or any environment variable override):
APP_NAME="My App"
This is the only runtime change needed — templates read {{ app_name }}
from settings.app_name, which is sourced from this variable. No template
edits required.
3. Superuser credentials — .env
FIRST_SUPERUSER_EMAIL=admin@yourapp.com
FIRST_SUPERUSER_PASSWORD=<strong-password>
4. Secret key — .env
SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
Never commit a real secret key. The placeholder in .env.example is intentionally weak.
5. Docker / Postgres identifiers — docker-compose.yml
The compose file uses app as the Postgres username, password, and database
name. Rename them consistently:
# db service
POSTGRES_USER: myapp
POSTGRES_PASSWORD: <strong-password>
POSTGRES_DB: myapp
# app service
DATABASE_URL: postgresql+asyncpg://myapp:<strong-password>@db:5432/myapp
6. This README
Replace the title and description with your project's own.
After these changes, run the normal setup:
uv sync --extra dev
cp .env.example .env # edit the values above
uv run python -m scripts.seed
uv run uvicorn app.main:app --reload
Quick start (local, SQLite)
# 1. Install dependencies (https://docs.astral.sh/uv/)
uv sync --extra dev
# 2. Configure (optional — sensible defaults exist)
cp .env.example .env # then edit SECRET_KEY etc.
# 3. Seed default roles/permissions + the first superuser
uv run python -m scripts.seed
# 4. Run
uv run uvicorn app.main:app --reload
Open:
- http://localhost:8000/ — HTMX dashboard (log in with the seeded superuser)
- http://localhost:8000/docs — interactive API docs
- http://localhost:8000/admin/users — admin dashboard
Default superuser (override via env/.env): admin@example.com / changethis.
Run with Docker (Postgres)
docker compose up --build
This starts Postgres, seeds the database, and serves the app on
http://localhost:8000. The app connects with
DATABASE_URL=postgresql+asyncpg://app:app@db:5432/app.
Configuration
Every setting is read from an environment variable first, then .env
(see .env.example). Key ones:
| Variable | Default | Notes |
|---|---|---|
SECRET_KEY |
dev placeholder | set a strong value in production |
DATABASE_URL |
sqlite+aiosqlite:///./app.db |
use postgresql+asyncpg://... for Postgres |
ACCESS_TOKEN_EXPIRE_MINUTES |
15 |
|
REFRESH_TOKEN_EXPIRE_DAYS |
7 |
|
AUTO_CREATE_TABLES |
true |
create tables on startup (dev); use Alembic in prod |
COOKIE_SECURE |
false |
set true behind HTTPS |
FIRST_SUPERUSER_EMAIL / _PASSWORD |
admin@example.com / changethis | used by seed |
Auth model
POST /api/auth/register— self-service signup (gets the defaultuserrole)POST /api/auth/login— OAuth2 password form (username= email) → access + refreshPOST /api/auth/refresh— rotates: the presented refresh token is revoked, a new pair is issuedPOST /api/auth/logout— revoke one refresh token (body) or all for the user
The JSON API authenticates via Authorization: Bearer <access>. The HTMX UI
stores the access token in an httpOnly cookie so pages work without client JS.
Refresh tokens are stored server-side as a SHA-256 hash + jti with a revoked
flag, so logout and rotation genuinely invalidate them.
RBAC
users → roles → permissions. Permissions follow resource:action
(users:read, users:write, users:delete, roles:read, roles:write).
Protect an endpoint with the dependency factory:
@router.get("", dependencies=[Depends(require_permission("users:read"))])
async def list_users(...): ...
Superusers (is_superuser=True) bypass all permission checks.
Migrations (Alembic)
AUTO_CREATE_TABLES is convenient for dev/SQLite. For real deployments use
migrations instead:
# apply the bundled initial migration
uv run alembic upgrade head
# after changing models, autogenerate the next revision
uv run alembic revision --autogenerate -m "describe change"
uv run alembic upgrade head
The Alembic env reads DATABASE_URL from app settings, so it targets the same
database as the app.
Tests
uv run pytest
Tests run against an in-memory SQLite database and cover the auth lifecycle (register/login/me/refresh/logout) and RBAC enforcement.
Where to go next
- Add your own models in
app/models/, schemas inapp/schemas/, routes inapp/api/orapp/web/. - Add permissions to
scripts/seed.pyand guard routes withrequire_permission(...). - Swap
HS256for asymmetricRS256keys if you need to verify tokens in other services.