Jesse A. Green / notebook

Full Stack FastAPI Template — Admin & Developer Guide

Generated June 2026 from fastapi/full-stack-fastapi-template at 38302d7

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

This guide is the architecture, configuration, security, deployment, and internals reference for the Full Stack FastAPI Template. It is written for the people who operate and extend a project built from this template: backend and frontend developers, DevOps engineers, and architects.

The template is a complete, integrated three-tier web application — a FastAPI/SQLModel backend, a React single-page frontend, and a PostgreSQL database — orchestrated by Docker Compose and fronted by a Traefik reverse proxy that terminates TLS. It is distributed as a Copier project template: you generate or clone it, then own and evolve the code.


1. Executive Summary

Aspect Detail
Purpose Production-ready scaffold for a full-stack web app with auth, CRUD, and CI/CD
Backend FastAPI, SQLModel (ORM), Pydantic, PostgreSQL, Alembic migrations
Frontend React 19, TypeScript, Vite, TanStack Query/Router/Table, Tailwind CSS, shadcn/ui
Auth JWT (HS256) bearer tokens; password hashing via Argon2 (with Bcrypt fallback)
API client Auto-generated TypeScript client from the backend's OpenAPI schema
Infra Docker Compose (dev + prod), Traefik proxy with automatic Let's Encrypt TLS
Tooling uv (Python), Bun (JS), Ruff + Biome (lint/format), Pytest + Playwright (tests)
CI/CD GitHub Actions — tests on PRs, deploy to staging on merge, production on release
Distribution Copier template; MIT licensed

The architecture's defining choice is a contract-first, generated client: the backend's OpenAPI schema is the single source of truth, and the frontend's API layer is generated from it. Backend and frontend stay in lockstep without hand-written glue.


2. Architecture

2.1 Component overview

                          Internet
                             │
                       ┌─────▼─────┐
                       │  Traefik  │  reverse proxy, TLS (Let's Encrypt)
                       │  (proxy)  │  routes by subdomain
                       └─────┬─────┘
            ┌────────────────┼────────────────┐
            │                │                │
   dashboard.<domain>   api.<domain>     adminer.<domain>
            │                │                │
      ┌─────▼─────┐    ┌─────▼─────┐    ┌─────▼─────┐
      │ frontend  │    │  backend  │    │  adminer  │
      │ nginx +   │    │ FastAPI   │    │ db web UI │
      │ React SPA │    │ (uvicorn) │    └─────┬─────┘
      └───────────┘    └─────┬─────┘          │
                             │                │
                       ┌─────▼────────────────▼─────┐
                       │      db (PostgreSQL 18)      │
                       │   volume: app-db-data        │
                       └──────────────────────────────┘

   prestart (one-shot): waits for db, runs Alembic migrations, seeds first superuser
   mailcatcher (dev only): captures outgoing email
   playwright (dev only): runs end-to-end tests

2.2 Request lifecycle (backend)

  1. A request arrives at Traefik, which routes api.<domain> to the backend container.
  2. FastAPI (app/main.py) applies CORS middleware (origins from settings.all_cors_origins) and dispatches to the API router mounted at settings.API_V1_STR (default /api/v1).
  3. The router (app/api/main.py) includes the login, users, utils, and items route modules — plus a private module only when ENVIRONMENT == "local".
  4. Protected endpoints resolve the CurrentUser dependency (app/api/deps.py), which decodes the JWT, loads the User from the database, and rejects inactive or unknown users.
  5. Route handlers use a request-scoped SQLModel Session (the SessionDep dependency) and return Pydantic/SQLModel response models, which FastAPI serializes to JSON.

2.3 Frontend architecture

  • Entry (frontend/src/main.tsx) wires up TanStack Query (QueryClient), TanStack Router (RouterProvider), the theme provider, and a toast host. It configures the generated client's base URL from import.meta.env.VITE_API_URL and supplies the bearer token from localStorage.
  • Auth interception: a shared query/mutation onError handler clears the token and redirects to /login on any 401/403, giving the SPA a single global auth-failure path.
  • Routing is file-based (TanStack Router). Routes under _layout require authentication — the _layout route's beforeLoad redirects to /login when no token is present. The generated route tree lives in routeTree.gen.ts.
  • Forms use react-hook-form with zod schemas for client-side validation (e.g. the login and signup schemas mirror the backend's field constraints).
  • Data access goes exclusively through the generated client services (LoginService, UsersService, ItemsService, …) wrapped in TanStack Query hooks — no hand-written fetch/axios calls in feature code.

2.4 The generated-client contract

The frontend's src/client/ directory is produced by @hey-api/openapi-ts from the backend's OpenAPI document. The backend gives each operation a stable, predictable ID via custom_generate_unique_id() in app/main.py, which names operations {tag}-{function_name} (e.g. items-read_items). This keeps the generated method names stable across regenerations. Regenerate with scripts/generate-client.sh whenever the API changes; the pre-commit hooks also regenerate it when backend files change.

Important

Treat frontend/src/client/ as build output, not source. Never edit it by hand — your changes are overwritten on the next generation. Change the backend, then regenerate.


3. Requirements

Requirement Notes
Docker Engine + Compose The supported way to run the full stack (dev and prod)
uv Python dependency/venv manager (for running the backend locally)
Bun or Node.js Frontend package manager / dev server (Bun recommended)
PostgreSQL 18 Provided by the db Compose service; or an external managed instance
A domain + DNS (production) Wildcard subdomain pointing at your server (see Deployment)

4. Configuration Reference

All runtime configuration is environment-driven. The authoritative schema is the Settings class in backend/app/core/config.py (a Pydantic BaseSettings), which reads from the project-root .env file. Values marked computed are derived, not set directly.

4.1 Core application settings

Variable Type Default Description
PROJECT_NAME str (required) Display name in API docs and emails
API_V1_STR str /api/v1 Base path prefix for all API routes
ENVIRONMENT enum local One of local, staging, production
DOMAIN str localhost Base domain; used by Traefik routing and TLS
FRONTEND_HOST str http://localhost:5173 Frontend base URL; used to build email links
STACK_NAME str (from .env) Docker Compose project/label name (no spaces or dots)
SECRET_KEY str random per-process Signs JWTs; must be set in deployments
ACCESS_TOKEN_EXPIRE_MINUTES int 11520 (8 days) Access-token lifetime
BACKEND_CORS_ORIGINS list/csv [] Allowed browser origins (comma-separated or JSON list)
all_cors_origins computed — BACKEND_CORS_ORIGINS (trailing slash stripped) plus FRONTEND_HOST
SENTRY_DSN URL/None None Enables Sentry when set and ENVIRONMENT != "local"

Note

SECRET_KEY defaults to a fresh random value generated at process start (secrets.token_urlsafe(32)). That is convenient locally but means tokens are invalidated on every restart and differ across replicas — so you must set a fixed SECRET_KEY for any real deployment.

4.2 Database settings

Variable Type Default Description
POSTGRES_SERVER str (required) Database hostname (db inside Compose)
POSTGRES_PORT int 5432 Database port
POSTGRES_USER str (required) Database user
POSTGRES_PASSWORD str "" Database password
POSTGRES_DB str "" Database name
SQLALCHEMY_DATABASE_URI computed — Built as postgresql+psycopg://… from the above

The connection string is assembled with Pydantic's PostgresDsn.build() using the psycopg (v3) driver — you never set the URI directly.

4.3 Email (SMTP) settings

Variable Type Default Description
SMTP_HOST str/None None SMTP server host; required to enable email
SMTP_PORT int 587 SMTP port
SMTP_USER str/None None SMTP username
SMTP_PASSWORD str/None None SMTP password
SMTP_TLS bool True Use STARTTLS
SMTP_SSL bool False Use implicit SSL
EMAILS_FROM_EMAIL email/None None "From" address
EMAILS_FROM_NAME str/None PROJECT_NAME "From" display name (defaults to project name)
EMAIL_RESET_TOKEN_EXPIRE_HOURS int 48 Lifetime of password-reset tokens
emails_enabled computed — True only when both SMTP_HOST and EMAILS_FROM_EMAIL are set

4.4 Bootstrap & operational settings

Variable Type Default Description
FIRST_SUPERUSER email (required) Email of the auto-seeded first admin
FIRST_SUPERUSER_PASSWORD str (required) Password of the auto-seeded first admin
EMAIL_TEST_USER email [email removed] Address used by the test-email utility

The following are consumed by the Docker Compose files (not the Settings class):

Variable Default Description
DOCKER_IMAGE_BACKEND backend Backend image name for your registry
DOCKER_IMAGE_FRONTEND frontend Frontend image name for your registry
TAG latest Image tag to deploy
VITE_API_URL per-build Frontend build arg: the API base URL baked into the SPA

4.5 The "changethis" guard

Two model_validator(mode="after") hooks in Settings enforce secret hygiene at startup. _enforce_non_default_secrets() checks SECRET_KEY, POSTGRES_PASSWORD, and FIRST_SUPERUSER_PASSWORD: if any equals changethis, it warns when ENVIRONMENT == "local" and raises a ValueError otherwise — so a misconfigured staging/production backend fails fast instead of running with a known-public secret.


5. Data Model

All models live in backend/app/models.py as SQLModel classes, which serve double duty as database tables and API schemas. The pattern is a *Base of shared fields, table models (table=True), and create/update/public variants.

5.1 Entities

User (table)                          Item (table)
├── id: UUID (pk)                      ├── id: UUID (pk)
├── email: str (unique, indexed)      ├── title: str (1–255)
├── is_active: bool = True            ├── description: str | None (≤255)
├── is_superuser: bool = False        ├── created_at: datetime (UTC)
├── full_name: str | None             ├── owner_id: UUID → user.id (CASCADE)
├── hashed_password: str              └── owner: User
├── created_at: datetime (UTC)
└── items: list[Item] (cascade delete)
  • Relationship: one User owns many Items. Deleting a user cascade-deletes their items (cascade_delete=True on the relationship and ondelete="CASCADE" on the FK).
  • Identifiers: primary keys are UUIDs (default_factory=uuid.uuid4), not sequential integers — safer to expose and migration-friendly.
  • Timestamps: created_at is timezone-aware UTC via a get_datetime_utc() factory.
  • Validation: passwords are constrained to 8–128 chars; emails use Pydantic EmailStr.

5.2 Schema variants

Class Role
UserCreate / UserRegister Admin-created vs. self-service signup payloads
UserUpdate / UserUpdateMe Full (admin) vs. self-service profile update
UpdatePassword / NewPassword Change-password vs. reset-via-token payloads
UserPublic / UsersPublic Single user vs. paginated list (data + count) responses
ItemCreate / ItemUpdate / ItemPublic / ItemsPublic Item CRUD + list schemas
Token / TokenPayload JWT response and decoded-claims models
Message Generic { "message": str } response

Tip

When you add your own resource, copy the Item family as a template: a *Base, a table=True model, and Create/Update/Public/*sPublic variants. This keeps request validation, DB schema, and API responses consistent and OpenAPI-friendly.


6. API Reference

All paths are relative to API_V1_STR (default /api/v1). "Auth" indicates the requirement: Public (none), User (valid token), or Superuser.

6.1 Authentication & password recovery (login)

Method Path Auth Purpose
POST /login/access-token Public OAuth2 password flow; returns an access token
POST /login/test-token User Validate the current token, return the user
POST /password-recovery/{email} Public Send a password-recovery email
POST /reset-password/ Public Reset a password using an emailed token
POST /password-recovery-html-content/{email} Superuser Preview recovery email HTML

6.2 Users (users)

Method Path Auth Purpose
GET /users/ Superuser List users (paginated)
POST /users/ Superuser Create a user (sends new-account email if enabled)
GET /users/me User Get the current user
PATCH /users/me User Update own profile
PATCH /users/me/password User Change own password
DELETE /users/me User Delete own account (superusers forbidden)
POST /users/signup Public Self-service registration
GET /users/{user_id} User Get a user by ID (own, or any if superuser)
PATCH /users/{user_id} Superuser Update any user
DELETE /users/{user_id} Superuser Delete any user (cannot delete self)

6.3 Items (items)

Method Path Auth Purpose
GET /items/ User List items (own; all if superuser), paginated
POST /items/ User Create an item owned by the caller
GET /items/{id} User Get an item (owner or superuser)
PUT /items/{id} User Update an item (owner or superuser)
DELETE /items/{id} User Delete an item (owner or superuser)

6.4 Utilities (utils) and private (private)

Method Path Auth Purpose
POST /utils/test-email/ Superuser Send a test email
GET /utils/health-check/ Public Liveness check, returns true
POST /private/users/ local only Create a user without restrictions (testing)

Caution

The private router is mounted only when ENVIRONMENT == "local". It bypasses normal validation and must never be exposed in staging or production — keep ENVIRONMENT correct on every deployment.

Pagination follows a consistent pattern: list endpoints accept skip (default 0) and limit (default 100) query parameters and return { "data": [...], "count": <total> }.


7. Security

7.1 Authentication

  • Tokens: JWT signed with HS256 using SECRET_KEY (app/core/security.py). The token's sub claim is the user's UUID; exp is set from ACCESS_TOKEN_EXPIRE_MINUTES.
  • Transport: the OAuth2 password flow (OAuth2PasswordBearer) at /login/access-token; clients send Authorization: Bearer <token>.
  • Resolution: get_current_user decodes the token, loads the User, and rejects unknown (404) or inactive (400) users; invalid tokens yield 403.
  • Authorization: get_current_active_superuser gates admin-only routes; per-object checks (e.g. item ownership) happen inside handlers.

7.2 Password handling

  • Hashing uses pwdlib with an Argon2 primary hasher and a Bcrypt fallback (PasswordHash((Argon2Hasher(), BcryptHasher()))).
  • verify_and_update transparently re-hashes a password on successful login if it was stored with an older scheme — so migrating hash algorithms is automatic over time.
  • Password length is bounded (8–128) at the schema layer to keep hashing predictable.

7.3 Attack-surface mitigations (built in)

Concern Mitigation in the code
Timing attacks on login authenticate() verifies against a constant DUMMY_HASH even when the email doesn't exist, equalizing response time
Email enumeration (recovery) /password-recovery/{email} always returns the same "if that email is registered…" message, sending mail only if the user exists
Email enumeration (reset) A non-existent user on /reset-password/ returns the same Invalid token error as a bad token
Self-lockout Superusers cannot delete themselves (403)
CORS Only origins in all_cors_origins may call the API from a browser
Secret hygiene Startup refuses changethis secrets outside local (see 4.5)

Warning

Tokens are long-lived (8 days by default) and there is no server-side revocation list. If you need immediate revocation (e.g. on compromise), shorten ACCESS_TOKEN_EXPIRE_MINUTES and/or add a token-version claim and check it against the user record.

7.4 Hardening checklist for production

  • [ ] Set strong, fixed SECRET_KEY, POSTGRES_PASSWORD, FIRST_SUPERUSER_PASSWORD.
  • [ ] Set ENVIRONMENT=production (disables the private router, enables Sentry, enforces secret checks).
  • [ ] Scope BACKEND_CORS_ORIGINS to your real frontend origins only.
  • [ ] Rotate the first superuser password after bootstrap, or disable the account.
  • [ ] Terminate TLS at Traefik and never expose the backend/db ports publicly.
  • [ ] Configure SENTRY_DSN for error visibility.

8. Operations

8.1 Service inventory (Docker Compose)

Service Image / build Role Exposed (dev)
db postgres:18 Database; health-checked with pg_isready 5432
prestart backend image One-shot: waits for DB, runs migrations, seeds superuser —
backend backend/Dockerfile FastAPI API (uvicorn) 8000
frontend frontend/Dockerfile nginx serving the built React SPA 5173
adminer adminer Web database admin UI 8080
proxy (override) traefik:3.6 Local reverse proxy + dashboard 80, 8090
mailcatcher (override) schickling/mailcatcher Captures dev email 1080, 1025
playwright (override) frontend/Dockerfile.playwright E2E test runner 9323

The prestart service runs scripts/prestart.sh and must complete successfully (service_completed_successfully) before backend starts — this is how migrations and seeding happen deterministically on every deploy.

8.2 Compose file layout

File Used when Purpose
compose.yml always Base stack: db, prestart, backend, frontend, adminer
compose.override.yml local dev (automatic) Adds proxy, mailcatcher, playwright; mounts source; enables --reload and port exposure
compose.traefik.yml production, once The shared public Traefik proxy (run separately on the server)

Important

In production, run with docker compose -f compose.yml ... explicitly so the development overrides in compose.override.yml (source mounts, exposed ports, mailcatcher) are not applied.

8.3 Common commands

# Start the full dev stack with live sync
docker compose watch
# Follow logs (all services, or one)
docker compose logs
docker compose logs backend
# Open a shell inside the running backend
docker compose exec backend bash
# Run the backend test suite against the running stack
docker compose exec backend bash scripts/tests-start.sh

8.4 Database migrations (Alembic)

Schema changes are managed with Alembic; migrations live in backend/app/alembic/versions/ and are applied automatically by prestart on startup.

# Inside the backend container, after editing app/models.py
alembic revision --autogenerate -m "Add column last_name to User"
alembic upgrade head

Commit the generated revision file to your repository. Alembic is pre-wired to import the SQLModel metadata from app/models.py.

Note

If you prefer not to use migrations at all, app/core/db.py documents how to switch to SQLModel.metadata.create_all(engine) instead — but migrations are strongly recommended for any project that will evolve.

8.5 Startup resilience

The backend_pre_start.py and tests_pre_start.py scripts use tenacity to retry a trivial SELECT 1 for up to 5 minutes (one attempt per second) until the database answers. This is why the first boot can take a minute — the backend is waiting for PostgreSQL's health check, not hung.


9. Deployment

The template deploys with Docker Compose to a remote server, behind a single public Traefik proxy that handles TLS for every stack on the host.

9.1 Preparation

  • A remote server with Docker Engine (not Docker Desktop) installed.
  • DNS configured with a wildcard subdomain (e.g. *.fastapi-project.example.com), so dashboard., api., adminer., and traefik. subdomains all resolve to the server.

9.2 One-time: the public Traefik proxy

Traefik runs once per server and is shared by all stacks. On the server:

# Create the shared Docker network the stacks attach to
docker network create traefik-public
# Provide Traefik's required env vars (dashboard basic-auth + ACME email)
export USERNAME=admin
export PASSWORD=<a-strong-password>
export HASHED_PASSWORD=$(openssl passwd -apr1 $PASSWORD)
export DOMAIN=fastapi-project.example.com
export EMAIL=[email removed]    # must be a real address for Let's Encrypt
# Start Traefik
docker compose -f compose.traefik.yml up -d

Traefik obtains and renews Let's Encrypt certificates automatically via the TLS challenge, storing them in the traefik-public-certificates volume.

9.3 Deploy the application stack

Copy the code to the server (respecting .gitignore), set the required environment variables, then build and start:

rsync -av --filter=":- .gitignore" ./ root@your-server:/root/code/app/
export ENVIRONMENT=production
export DOMAIN=fastapi-project.example.com
export SECRET_KEY=<generated>
export POSTGRES_PASSWORD=<generated>
export FIRST_SUPERUSER_PASSWORD=<generated>
export BACKEND_CORS_ORIGINS="https://dashboard.${DOMAIN},https://api.${DOMAIN}"
cd /root/code/app/
docker compose -f compose.yml build
docker compose -f compose.yml up -d

Traefik routes traffic to each service by subdomain via the labels in compose.yml: api.<domain> → backend (port 8000), dashboard.<domain> → frontend (port 80), adminer.<domain> → adminer.

9.4 Continuous Deployment (GitHub Actions)

The repository ships workflows for automated deployment to two GitHub Environments:

  • staging — deploys after pushing/merging to master.
  • production — deploys after publishing a release.

Both run on self-hosted GitHub Actions runners labeled staging / production that you install on your server(s) as a service. Configure these per-environment secrets:

Secret Used for
DOMAIN_PRODUCTION / DOMAIN_STAGING Target domains
STACK_NAME_PRODUCTION / STACK_NAME_STAGING Compose stack/label names
SECRET_KEY JWT signing
POSTGRES_PASSWORD Database password
FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD Seed admin
EMAILS_FROM_EMAIL Outgoing mail sender
LATEST_CHANGES Auto release-note generation (PAT)
SMOKESHOW_AUTH_KEY Publishing coverage reports

Tip

Use GitHub environment secrets (not repository secrets) so they are scoped to staging vs. production and can carry deployment protection rules (required reviewers, wait timers).


10. Testing & Quality

Layer Tool How to run
Backend unit/integration Pytest docker compose exec backend bash scripts/tests-start.sh
Backend coverage coverage.py Generates backend/htmlcov/index.html
Frontend end-to-end Playwright cd frontend && bunx playwright test (stack running)
Python lint/format Ruff via pre-commit / uv run ruff
Python type-check mypy and ty via pre-commit
JS/TS lint/format Biome bun run lint

Pre-commit hooks (configured in .pre-commit-config.yaml, run via prek) enforce these on every commit and also regenerate the frontend client when backend files change and add release dates to release-notes.md.

# Install the git hook (from the backend folder)
uv run prek install -f

# Run all hooks manually across the repo
uv run prek run --all-files

Note

The pre-commit pipeline runs Ruff, ruff-format, Biome, mypy, ty, and the SDK generator. Run it locally before pushing to avoid CI failures and stale clients.


11. Repository Structure

.
├── backend/                  FastAPI backend (Python, uv)
│   ├── app/
│   │   ├── main.py           FastAPI app + CORS + router mount
│   │   ├── models.py         SQLModel models = tables + API schemas
│   │   ├── crud.py           DB create/read/update helpers
│   │   ├── utils.py          Email rendering/sending, reset-token helpers
│   │   ├── api/
│   │   │   ├── main.py       API router aggregation
│   │   │   ├── deps.py       Auth/session dependencies
│   │   │   └── routes/       login, users, items, utils, private
│   │   ├── core/
│   │   │   ├── config.py     Settings (env schema) — config source of truth
│   │   │   ├── db.py         Engine + initial-data seeding
│   │   │   └── security.py   JWT + password hashing
│   │   ├── alembic/          Migrations
│   │   └── email-templates/  MJML sources + built HTML
│   ├── Dockerfile
│   └── pyproject.toml
├── frontend/                 React SPA (TypeScript, Bun, Vite)
│   ├── src/
│   │   ├── main.tsx          App bootstrap (Query + Router + theme)
│   │   ├── client/           GENERATED OpenAPI client (do not edit)
│   │   ├── routes/           File-based pages (login, signup, _layout/*)
│   │   ├── components/       UI + feature components (shadcn/ui based)
│   │   └── hooks/            useAuth and helpers
│   ├── Dockerfile
│   └── package.json
├── compose.yml               Base stack
├── compose.override.yml      Dev overrides
├── compose.traefik.yml       Public proxy (prod)
├── copier.yml                Template input variables
├── .env                      Configuration values
└── scripts/                  generate-client, test, prestart helpers

12. Extending the Template — Adding a Resource

The cleanest path to your own feature mirrors how Item is built:

  1. Model — add YourThingBase, a table=True model, and Create/Update/Public variants in backend/app/models.py.
  2. CRUD — add helpers in backend/app/crud.py if your logic exceeds simple session calls.
  3. Routes — create backend/app/api/routes/your_things.py with an APIRouter (prefix + tag), then include it in backend/app/api/main.py.
  4. Migration — alembic revision --autogenerate -m "..." and commit it.
  5. Client — run bash ./scripts/generate-client.sh to regenerate the typed client.
  6. Frontend — add a route under frontend/src/routes/_layout/ and use the generated YourThingsService through TanStack Query hooks.

Tip

Give every route module a single, descriptive tags=[...] entry — main.py's custom_generate_unique_id uses the first tag to name generated client methods, so consistent tags produce clean, stable frontend service names.


13. Potential Improvements & Considerations

These are observations for teams adopting the template, not defects — the template is intentionally minimal so you can shape it.

  • Token revocation: there is no refresh-token or revocation mechanism. For security-sensitive apps, consider shorter access-token lifetimes plus a refresh flow, or a token-version claim checked against the user.
  • Rate limiting: the API has no built-in rate limiting on login or recovery endpoints. Add one (e.g. at Traefik or via middleware) to blunt brute-force attempts.
  • deploy.sh/secrets in env: production secrets are passed as shell environment variables in the documented flow; integrate a secrets manager for stronger handling.
  • Single backend replica: because a random SECRET_KEY is generated per process when unset, always pin SECRET_KEY before scaling the backend horizontally.
  • Items as a placeholder: the Item resource exists only as a worked example — remove it once you've copied the pattern for your real domain models.