A modern, production-ready FastAPI template utilizing a clean, layered (hexagonal-style) architecture. This template is designed for building robust and scalable backend services with clear separation of concerns, easy testing, and high maintainability.
While FastAPI is incredibly fast and flexible, it doesn't enforce a specific project structure. As projects grow, they often turn into a tangled mess of tightly coupled route handlers, business logic, and database calls. This template provides Enterprise-Grade Readiness from minute zero.
- Pre-Configured Tooling:
uv,pytest,ruff, andalembicare pre-integrated. No wrestling with environment setups. - Scalable Architecture: Extends beyond simple MVC. It isolates API routing, business logic (Services), and database interactions (Repositories) making the app highly testable and maintainable.
- Production-Ready Security & Observability: JWT authentication, HttpOnly cookies for refresh tokens, token blacklisting, password hashing, and rate limiting are baked in. Out-of-the-box integration with Sentry for robust error monitoring.
- Consistent Responses: Global exception handlers utilizing centralized success and error messages prevent hardcoded strings and standardize the API response structures.
- Docker First: A
docker-compose.yamlis ready to spin up your backend, PostgreSQL database, and Redis instances instantly.
This backend template is designed to seamlessly integrate with the companion Next.js 16 + React 19 + TypeScript Enterprise Template. You can find the frontend template here: kemalcalak/NextJS-Template.
The two templates share the same auth contract: HttpOnly access_token / refresh_token cookies, /api/v1 prefix, X-Requested-With CSRF header, and a uniform { success, data, message, error } response envelope.
This template integrates the best-in-class Python ecosystem tools to provide a seamless developer experience:
- Framework: FastAPI for building APIs with Python 3.12+ based on standard Python type hints.
- Architecture: Strict Layered Architecture separating routers, services, repositories, use cases, and models, fully utilizing FastAPI's dependency injection.
- Database & ORM: SQLAlchemy 2.0 with
asyncpgfor non-blocking operations, and Alembic for schema migrations. - Observability & Error Tracking: Sentry built-in integration for tracking unhandled exceptions and performance tracing.
- Caching: Redis integration using
redis.asynciofor robust, high-performance distributed caching. - Validation & Config: Pydantic v2 and
pydantic-settingsfor robust data validation and environment management. - Security & Auth: JWT access/refresh tokens accepted via either HttpOnly cookies or
Authorization: Bearer,bcryptpassword hashing, Redis-backed token blacklist (logout invalidation), strict origin-check middleware (returns 404 for foreign origins), and Slowapi rate limiting for brute-force protection. - Account Lifecycle: Email verification, password reset, password change, and soft-delete with grace period β accounts marked for deletion can be reactivated until the cron worker purges them.
- File Uploads & Avatars: Cloudinary-backed image uploads via
POST /upload(image-only, 5 MB cap, rate-limited + audited). A genericFilemodel lets any feature attach uploads; user avatars enforce ownership (IDOR) guards β you may only attach a file you uploaded β and auto-delete the replaced avatar's asset. Admins get full file management (list/filter by uploader, hard-delete with avatar references auto-cleared). See File Uploads & Avatars below. - Background Jobs: arq worker (separate container in compose) runs cron jobs such as
delete_expired_accountsandpurge_stale_sessionsat the configured time. - Audit Trail:
user_activitytable records auth events and CRUD actions with IP / user agent, plus the HTTP status code of each event (200on success, the raised error code on failure) β filterable in the admin panel. Theaudit_unexpected_failuredecorator captures unexpected route failures. - Smart Email Validation & Delivery: Built-in asynchronous email sending with SMTP, domain MX record checking using
dnspython, and auto-updating disposable email provider filtering via Redis cache. - Standardized API Responses: Global exception handlers standardizing success/error schemas, utilizing a centralized
messagesmodule (app/core/messages/) to prevent hardcoded responses. - Role-Based Access Control (RBAC): Three roles β
user/admin/superadmin. Superadmins bypass every check and create/delete admins and assign their permissions; plain admins are gated perresource:actionpermission (users:read,users:write,support:update, β¦) stored inadmin_permissionand enforced by a singlerequire_permission(...)dependency. The superadmin tier (promote an admin to superadmin, demote a superadmin, transfer root via email OTP) is reserved for the root superadmin (is_root_superadmin); the last active superadmin is always protected. See Admin & RBAC. - Support / Ticketing System: Full ticket lifecycle β open, reply with image attachments, status/priority, admin assignment β fanned out over a Redis-backed WebSocket bus so the ticket thread, the admin queue, and per-user feeds update live across workers. See Support System & Realtime.
- Notification System: Persistent in-app notifications (
notificationtable) layered on the same realtime bus β domain events (a support reply, a ticket status change, an RBAC grant change) call a singlenotify()use case that stores the row and pushes it to the recipient'snotifications:{id}feed, so offline users still find it in their inbox on the next fetch. Notification copy is stored as a stable machinetype+datapayload (no human text), so the frontend renders it in any locale. REST covers paginated listing (unread_onlyfilter), the unread badge count, and mark-(all-)read. See Notification System. - Broadcast / Announcements: Admins compose an announcement β from a reusable template catalog (typed variables: localized datetimes + per-language text) or custom per-language text β and fan it out to an audience (
all/active/ byrole). Each recipient gets a persistent notification via a chunkednotify_manyuse case (bulk insert + realtime), and optionally a multilingual email sent in throttled batches by anarqjob. Content is stored language-agnostically so it renders in each user's locale; datetimes are stored UTC. Ashow_bannerflag exposes the latest as a top banner (GET /announcements/active). Gated bybroadcast:read/broadcast:write. - System Settings: Runtime-editable, registry-backed key-value settings β a typed code registry (
core/settings_registry.py) is the single source of truth (type, default, category,is_public, validation) and thesystem_settingtable stores only admin overrides, so a missing row falls back to the env/registry default. An unauthenticatedGET /settings/publicexposes onlyis_publicsettings (site name, logo URL, maintenance flag, β¦) for the login / maintenance screens; admins read & patch all via RBAC (system_settings:read/system_settings:write). Flags are wired to real behaviour: a maintenance-mode middleware 503s non-admins, and registration / support / max-upload-size are enforced from the settings. See System Settings. - Session / Device Management: Every login opens a
user_sessionrow whose id rides in both tokens as the JWTsidclaim, so a user can list their active devices and revoke any one (or all but the current). Refresh rotates the session'sjtiand detects replay β a stale refresh token revokes the whole session as compromised; logout and password change revoke sessions too. Revocation kills a session's still-valid access tokens instantly via a Redissidflag, and broadcastssessions_revokedso open tabs drop to login live. Admins can list/terminate a user's sessions, gated by theusers:sessionspermission. A nightly arq job purges stale rows. See Session Management. - First Superadmin Seed: On startup a root superadmin (
is_root_superadmin) is created fromFIRST_SUPERUSER/FIRST_SUPERUSER_PASSWORDif none exists (a matching existing account is promoted instead; older deployments get their oldest superadmin flagged as root) β guaranteeing a root account. - Tooling: uv for blazing-fast package management, and Ruff for linting and formatting.
- Testing: Comprehensive async testing setup with
pytestandpytest-asyncio, in-memory SQLite viaaiosqlite,fakeredis, and autouse SMTP/MX patches β tests never hit Postgres or the network.
The repository structure supports standard Continuous Integration pipelines out-of-the-box. Ensure you configure your CI (GitHub Actions, GitLab CI, etc.) to run:
- Dependency Install:
uv sync - Linting:
uv run ruff check . - Formatting Check:
uv run ruff format --check . - Unit & Integration Tests:
uv run pytest
- Python >= 3.12
- Docker & Docker Compose (for local database and Redis)
uvpackage manager (recommended)
Clone the repository to your local machine:
git clone https://github.com/kemalcalak/fastapi-template.git
cd fastapi-templateCreate a .env file from the provided template:
cp .env.example .envYour .env file should look like this, filled with your actual configuration:
# Application Settings
PROJECT_NAME="FastAPI Template"
SECRET_KEY="changethis"
ENVIRONMENT="local"
FIRST_SUPERUSER="admin@example.com"
FIRST_SUPERUSER_PASSWORD="changethis"
FRONTEND_HOST="http://localhost:5173"
# Comma-separated Host header allowlist enforced in production only.
ALLOWED_HOSTS=""
# Host address Docker binds the DB/Redis ports to (loopback by default).
DOCKER_BIND_HOST="127.0.0.1"
# Database Settings
POSTGRES_SERVER="localhost"
POSTGRES_PORT=5432
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="changethis"
POSTGRES_DB="app"
# Redis Cache Settings
REDIS_URL="redis://localhost:6379/0"
# Email / SMTP Settings (Optional)
SMTP_HOST="smtp.example.com"
SMTP_PORT=465
SMTP_USE_STARTTLS=True
SMTP_USE_SSL=False
SMTP_USER="smtp_username"
SMTP_PASSWORD="smtp_password"
EMAILS_FROM_EMAIL="noreply@example.com"
# Cloudinary (file/avatar uploads). Required to use POST /upload.
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
CLOUDINARY_UPLOAD_FOLDER="uploads"
# Max upload size in bytes (default 5 MB). Uploads above this are rejected.
MAX_UPLOAD_SIZE_BYTES=5242880
# Sentry (only initialized when ENVIRONMENT != "local")
SENTRY_DSN=This project provides a docker-compose.yaml to spin up the entire stack, including the backend service, a local PostgreSQL instance, a Redis container, and the arq background worker:
docker-compose up -d --buildThe API will be available at http://localhost:8000. You can test the endpoints via the Swagger UI available at http://localhost:8000/docs.
To run the worker manually (e.g. when developing the API on the host):
uv run arq app.worker.settings.WorkerSettingsTo generate and apply the database tables using Alembic, run the migration command inside the backend container:
# Apply existing migrations
docker-compose exec backend uv run alembic upgrade headIf you modify models in app/models/ and need to generate a new migration script:
docker-compose exec backend uv run alembic revision --autogenerate -m "description_of_changes"
docker-compose exec backend uv run alembic upgrade headTests are written using pytest and configured for async execution with pytest-asyncio. Configuration details can be found in pytest.ini.
To run the test suite locally:
uv run pytestThe loadtest/ directory contains Locust scenarios for measuring capacity. Run the API with ENVIRONMENT=local so rate limiting is disabled and the test measures real throughput rather than the limiter.
-
Seed verified test accounts directly into the database (the login flow rejects unverified users, so they cannot be created via the public register endpoint):
uv run python -m loadtest.seed_users # 50 accounts by default (LT_USER_COUNT) -
Run a scenario β pick the user class by name:
uv run locust -f loadtest/locustfile.py ActiveUser --host http://localhost:8000
Available scenarios:
ActiveUserβ realistic mixed workload: log in once, then reads (GET /users/me), token refreshes, and profile updates with think time. Use this to gauge concurrent active users.HealthCeilingUserβ raw throughput ceiling (GET /health/live, no DB, no wait).LoginStormUserβ bcrypt-bound login throughput.
Tune behaviour via env vars: LT_USER_COUNT, LT_PASSWORD, LT_WAIT_MIN / LT_WAIT_MAX, LT_API_PREFIX. For non-interactive runs add --headless -u <users> -r <spawn-rate> -t <duration>.
This project uses Ruff for both code linting and formatting. The configurations are specified in pyproject.toml.
- Check for issues:
uv run ruff check . - Format code:
uv run ruff format . - Auto-fix lint issues:
uv run ruff check --fix .
The repo ships a .pre-commit-config.yaml. After cloning, install both hook stages once:
uv run pre-commit install --hook-type pre-commit --hook-type pre-pushpre-commit (~5β15 s) β runs on every git commit against staged files:
trim trailing whitespace,end-of-file-fixer,check-yaml,check-toml,check-added-large-files,check-merge-conflict,detect-private-keyruff check --fix(auto-fix lint)ruff format
pre-push (~30β60 s) β runs on every git push:
uv run pytestβ full test suite must pass before code leaves the machine.
To run all hooks manually against the whole repo: uv run pre-commit run --all-files.
prometheus-fastapi-instrumentator collects metrics for every handled request. The /metrics endpoint (root path, outside /api/v1, hidden from Swagger) exposes them in the standard Prometheus exposition format. Default metrics: request count, latency histograms, in-progress requests, exceptions per handler, plus the standard Python runtime + process metrics. Health endpoints and /metrics itself are excluded from instrumentation to avoid noise.
Auth model β three layers:
include_in_schema=Falseβ the endpoint is invisible in Swagger / OpenAPI.- Environment-gated bearer token β outside
ENVIRONMENT="local", the endpoint requiresAuthorization: Bearer ${METRICS_TOKEN}. Mismatched or missing tokens return 404 (not 401/403) so the endpoint's existence is not disclosed. origin_check_middlewareβ browser cross-origin requests with a foreignOriginheader are rejected by the global middleware, regardless of the token.
Local dev β open access:
curl http://localhost:8000/metricsProduction / staging β bearer token required:
# .env (or your secret store)
METRICS_TOKEN=$(openssl rand -hex 32)
# Prometheus scrape config (prometheus.yml)
scrape_configs:
- job_name: fastapi
authorization:
type: Bearer
credentials: <your METRICS_TOKEN>
static_configs:
- targets: ['api.example.com:8000']Even with the token, prefer to also restrict
/metricsat the reverse proxy (Prometheus scraper IP allowlist or VPC-internal-only exposure). Defense-in-depth β the token is one layer, network policy is another.
Metrics tell you what is slow ("p99 latency on /users/{id} doubled at 14:02"). Traces tell you why β a single request's full waterfall: route handler β SQLAlchemy queries β Redis calls β outbound httpx requests, each with its own span and timing.
Opt-in by design. When OTEL_EXPORTER_OTLP_ENDPOINT is unset, init_telemetry() returns early and there is zero overhead β no spans created, no exporter started, no extra allocations. Local dev and the test suite stay clean.
Wiring (app/core/telemetry.py):
| Layer | Instrumentation | What you get |
|---|---|---|
| FastAPI | FastAPIInstrumentor |
Per-request span named after the route template (/users/{id}, not /users/42); /metrics and /health excluded |
| SQLAlchemy | SQLAlchemyInstrumentor |
One span per query, with the SQL statement and duration |
| Redis | RedisInstrumentor |
One span per Redis call (cache hits, rate-limit checks, pub/sub) |
| httpx | HTTPXClientInstrumentor |
Outbound HTTP spans (e.g. disposable-email blocklist refresh) |
Enabling traces: point OTEL_EXPORTER_OTLP_ENDPOINT at any OTLP/HTTP collector β Tempo, Jaeger, Honeycomb, the OTel Collector, etc.
# .env
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_SERVICE_NAME=fastapi-templateThe SDK reads every other OTEL_* variable natively (sampler, headers, batch size, etc.) β see the OTel SDK environment variables reference for the full list.
Image uploads are backed by Cloudinary. The binary lives in Cloudinary; Postgres stores only metadata (url, public_id, content_type, size, uploader) in a generic file table β so any feature (avatars today, galleries or attachments later) attaches an upload through its own file_id column rather than the File model referencing it.
Opt-in by design. The Cloudinary credentials are optional at boot β the app starts fine without them. Uploads then fail clearly at request time (500, with a config error logged) rather than silently. Set the four CLOUDINARY_* vars (see .env.example) to enable POST /upload.
| Method & path | Auth | Purpose |
|---|---|---|
POST /upload |
any active user | Upload one image, return its FilePublic metadata. Rate-limited (20/minute) and audit-logged. |
PATCH /users/me |
self | Attach or clear your own avatar via avatar_file_id. |
GET /admin/files |
superuser | List / paginate uploads; filter by content_type or uploader (case-insensitive name / email match). |
GET /admin/files/{id} |
superuser | A single file's admin view (includes uploader identity and public_id). |
DELETE /admin/files/{id} |
superuser | Hard-delete the Cloudinary asset and the DB row. |
Rules baked in:
- Images only, 5 MB cap β
image/jpeg,image/png,image/webp,image/gif. Oversized β413, wrong type β415, empty β400, Cloudinary failure β502. Limits live inMAX_UPLOAD_SIZE_BYTESand the upload service's MIME whitelist. - Ownership (IDOR) guard β you may only set your avatar to a file you uploaded; pointing at someone else's file returns
403. - Replaced-file cleanup β swapping your avatar deletes the previous file's Cloudinary asset and DB row automatically, so no orphans accumulate.
- Safe deletes β both
user.avatar_file_idandfile.uploaded_by_idareON DELETE SET NULL, so deleting a file never leaves a dangling avatar and a file outlives the user who uploaded it.
Three roles layer the admin surface from least to most privileged:
| Role | Access |
|---|---|
user |
Regular app user. No admin surface. |
admin |
Reaches the admin panel, but every action is gated by the permissions granted to them. |
superadmin |
Bypasses all permission checks; creates/deletes admins and assigns their permissions. |
superadmin (root) |
The first seeded superadmin (is_root_superadmin). Additionally owns the superadmin tier: promote an admin to superadmin, demote a superadmin, and transfer root. |
Permission model. Permissions are resource:action keys β users:read, users:write, users:delete, users:suspend, users:password_reset, files:read, files:delete, support:read, support:write, support:update, activities:read, stats:read (12 in total). A plain admin's grants live in the admin_permission table; superadmins implicitly hold all of them.
Enforcement. Every admin endpoint declares exactly what it needs with one dependency:
@router.get("")
async def list_users(
_admin: Annotated[User, Depends(require_permission(Permission.USERS_READ))],
session: SessionDep,
): ...require_permission chains auth β active account β admin role β grant lookup, and superadmins short-circuit. Payload-conditional actions are enforced on the same layer (reassigning a ticket needs support:update). GET /users/me returns the caller's permissions (for admins only) plus is_root_superadmin, so the frontend mirrors the exact gating.
Hard invariants (services/admin/):
- There is no userβadmin role transition on the users surface β admins are provisioned as accounts and removed by deletion (there is no demote-to-user path).
- The superadmin tier is root-only: only the root superadmin may promote an admin to superadmin or demote a superadmin back to admin.
- The root superadmin's own role is immutable; it can only be handed over via the email-OTP root transfer (a one-time code is sent to the current root's address).
- A superadmin can only be modified/deleted by another superadmin, and the last active superadmin is always protected.
Managing admins (superadmin-only β routes/admin/admins.py):
| Method & path | Purpose |
|---|---|
GET /admin/admins |
List admin-tier accounts with their permissions. |
GET /admin/admins/permissions |
The full permission catalog (powers the grant UI). |
POST /admin/admins |
Create a new admin account with an initial permission set. |
PATCH /admin/admins/{id}/permissions |
Replace an admin's grants. |
DELETE /admin/admins/{id} |
Delete an admin account. |
POST /admin/admins/{id}/promote |
Promote an admin to superadmin (root only). |
POST /admin/admins/{id}/demote |
Demote a superadmin back to admin (root only). |
POST /admin/admins/transfer-root |
Email a one-time code to begin a root transfer (root only). |
POST /admin/admins/transfer-root/confirm |
Confirm the OTP and hand over root (root only). |
After any grant or tier change a permissions_updated realtime event is pushed to the affected account(s) (see below), so their session re-fetches /users/me and the UI updates without a re-login.
A complete support desk built on a generic realtime bus that any feature can reuse.
| Method & path | Auth | Purpose |
|---|---|---|
POST /support/tickets |
user | Open a ticket (optional image attachments). |
GET /support/tickets Β· /{id} |
owner | List / view own tickets and message thread. |
POST /support/tickets/{id}/messages |
owner | Reply to own ticket. |
GET /admin/support/tickets Β· /{id} |
support:read |
Admin queue + full ticket view. |
POST /admin/support/tickets/{id}/messages |
support:write |
Admin reply (auto-assigns when unassigned). |
PATCH /admin/support/tickets/{id} |
support:write (+ support:update to reassign) |
Change status / priority / assignee. |
WS /admin/support/ws Β· /support/ws |
admin / owner | Multiplexed ticket + queue feed. |
WS /users/me/events |
any user | Per-user account feed (e.g. live permissions_updated). |
Realtime bus (app/core/realtime.py). A thin in-process ConnectionManager sits behind a Redis pub/sub bridge: services publish() to a topic β ticket:{id}, admin, user:{id}, account:{id}, or notifications:{id} β and a per-worker listener fans the event to the matching local sockets, so an event raised in one worker reaches clients connected to any worker. The account:{id} topic carries both permissions_updated (RBAC grant change) and sessions_revoked (a device was logged out). Publishing is best-effort (publish_safe), so a realtime hiccup never breaks the originating request.
Persistent, per-user in-app notifications built on the same realtime bus as support β so an event raised in any worker reaches the recipient live, and is still waiting in their inbox if they were offline.
| Method & path | Auth | Purpose |
|---|---|---|
GET /notifications |
self | List own notifications, newest first (skip / limit, unread_only filter). |
GET /notifications/unread-count |
self | Unread count for the bell badge. |
POST /notifications/{id}/read |
owner | Mark one notification read (idempotent; IDOR-guarded). |
POST /notifications/read-all |
self | Mark every unread notification read in one set-based UPDATE. |
WS /notifications/ws |
self | Live feed β new notifications arrive as notification_created frames. |
Emitting (app/use_cases/notify.py). Any domain service calls one cross-cutting notify(session, user_id, type, data) β it persists the row and best-effort publish_safes it to the recipient's notifications:{id} topic (mirroring log_activity, so a service never has to call another service). It is wired into admin support replies (support_ticket_replied), ticket status changes (support_ticket_status_changed), and RBAC grant changes (admin_permissions_changed); self-actions are skipped so nobody is notified about their own action.
Locale-free storage. A notification stores a stable machine type plus a data JSON payload (ids, subject, status) β never human text β so the frontend produces the message in the active language. Adding a type means adding an enum value plus a locale entry; the table schema never changes.
Runtime-editable, site-wide settings an admin can change without a redeploy (site name, logo, maintenance mode, feature toggles, limits).
Registry is the single source of truth. core/settings_registry.py defines every setting as a typed, immutable SettingDefinition β value_type (bool / int / string), default, category, is_public, and an optional validator. The system_setting table stores only admin-overridden values (key β serialised value); a setting with no row falls back to its registry default. Several defaults are seeded from the environment at import (site_name β PROJECT_NAME, default_locale β DEFAULT_LANGUAGE, max_upload_size_mb β MAX_UPLOAD_SIZE_BYTES), so settings are initialised from env yet remain runtime-editable. Adding a setting is one registry entry β no migration.
| Method & path | Auth | Purpose |
|---|---|---|
GET /settings/public |
public | Only is_public settings as key β typed value (site name, logo, maintenance flag, β¦); powers the login & maintenance screens before auth. |
GET /admin/system-settings |
system_settings:read |
Every setting with metadata (type, category, current value, last editor). |
PATCH /admin/system-settings/{key} |
system_settings:write |
Update one setting; validated against the registry (type + validator) and audited. |
Typed access (use_cases/settings.py). get_bool / get_int / get_str resolve a setting to its typed value (DB override β registry/env default). They live in use_cases/ so any service can read a setting without a service-to-service call.
Behavioural bindings. Settings drive real behaviour: a maintenance_mode_middleware returns 503 to non-admins while maintenance is on (auth, health and /settings/public stay open so admins can sign in); registration_enabled gates registration, support_enabled gates new tickets, and max_upload_size_mb caps uploads. The logo is uploaded through the normal file pipeline (Cloudinary + file table) under the branding_logo category and its URL is stored in logo_url.
Per-device login sessions backed by the user_session table, so a user can see where they're signed in and revoke access remotely. Each login mints an access + refresh pair carrying the session id as the JWT sid claim; the row records the device user-agent (parsed into browser/OS at the schema layer β the raw IP stays server-side and is never returned).
| Method & path | Auth | Purpose |
|---|---|---|
GET /users/me/sessions |
self | List own active sessions (paginated), current device flagged. |
DELETE /users/me/sessions/{id} |
owner | Revoke one of own sessions (foreign / unknown id β uniform 404). |
DELETE /users/me/sessions |
self | Revoke every session except the current device. |
GET /admin/users/{id}/sessions |
users:sessions |
List a user's active sessions (admin view). |
DELETE /admin/users/{id}/sessions |
users:sessions |
Terminate all of a user's sessions (remote logout). |
Rotation & replay (app/services/auth_service.py). Refresh validates the sid, rotates the session's stored refresh_jti, and pushes the expiry forward. A presented refresh token whose jti no longer matches the row is a replay β the whole session is revoked as compromised and audited, so a leaked token is single-use even if the Redis blacklist ever loses state. Legacy tokens minted before the feature (no sid) are rejected on refresh; the holder simply logs in again.
Instant revocation. Revoking a session blacklists its tokens and writes a revoked:session:{sid} Redis flag (TTL = access-token lifetime), which get_current_user checks on every request β so a still-valid access token dies the moment its session is killed, without a per-request DB hit. The same paths broadcast sessions_revoked on the account:{id} topic so a kicked device's open tab drops to login live. Logout, password change (revokes all of the user's sessions), and admin termination all flow through here.
Housekeeping. A nightly arq cron (app/worker/jobs/purge_stale_sessions.py) deletes expired rows immediately and revoked rows after a retention window (SESSION_REVOKED_RETENTION_DAYS, default 30) in one set-based DELETE.
βββ app/
β βββ alembic/ # Alembic env + versions/ (generated migration scripts)
β βββ api/ # API Layer: routers, deps.py, exception handlers, decorators
β β βββ routes/
β β βββ auth.py, users.py, files.py, support.py, health.py
β β βββ admin/ # Admin surface β RBAC-gated via require_permission;
β β # admins/ (admin management) is superadmin-only
β βββ core/ # config, db, security, redis, rate_limit, email, storage, realtime, bootstrap, messages/
β βββ models/ # Domain Layer: SQLAlchemy ORM (User, UserSession, UserActivity, File, SupportTicket, Notification, AdminPermission, β¦)
β βββ repositories/ # Data Layer: async DB queries (no business rules)
β βββ schemas/ # Pydantic v2 DTOs (Create / Update / Response per domain)
β βββ services/ # Business Logic Layer: pure async functions, take AsyncSession
β βββ use_cases/ # Cross-domain orchestration (e.g. activity logging, notifications)
β βββ worker/ # arq worker β settings + cron jobs (e.g. account deletion)
β βββ utils/ # Helper functions (datetime, email templates)
β βββ tests/ # pytest suite (in-memory SQLite, fakeredis, mocked SMTP)
β βββ main.py # FastAPI app, lifespan, CORS + origin middleware
βββ alembic.ini # Alembic settings
βββ docker-compose.yaml # Compose stack: backend + worker + db + redis
βββ dockerfile # Backend image (uv-based multi-stage build)
βββ pyproject.toml # Project dependencies and tool configurations
βββ pytest.ini # Pytest settings
βββ uv.lock # Dependency lock file (commit alongside dep changes)- Clone the repository and
cd fastapi-template - Copy
.env.exampleβ.envand setSECRET_KEY,FIRST_SUPERUSER, andFIRST_SUPERUSER_PASSWORD - Start the stack with
docker-compose up -d --build(backend + worker + PostgreSQL + Redis) - Apply migrations:
docker-compose exec backend uv run alembic upgrade head - Open the Swagger UI at
http://localhost:8000/docs - Confirm the first superadmin was seeded from
FIRST_SUPERUSERon startup, then log in with it - Create an admin account and assign
resource:actionpermissions from the admin endpoints (superadmin-only) - Log in as that permission-limited admin and verify each route is gated by
require_permission(...) - Try the support flow: open a ticket, then reply/assign it as an admin and watch the WebSocket bus push updates live
- Reply to that ticket as an admin and confirm the owner receives a notification at
GET /notifications(and live over thenotifications:{id}feed) - Set Cloudinary keys (
CLOUDINARY_*) if you needPOST /uploadfor files/avatars - Run the test suite with
uv run pytestto confirm everything is green - Add a new message constant to
app/core/messages/before introducing any user-facing string
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
This project follows Conventional Commits.
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'feat: Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
Distributed under the MIT License. See the LICENSE file at the root of the workspace for more information.