Deployment
A production deployment is one FastAPI process (plus optional Celery workers) behind a reverse proxy, backed by Postgres and Redis. Nothing exotic — standard 12-factor plumbing.
Production checklist
Before serving traffic:
- [ ]
SM_ENVIRONMENTset to something other thandevelopment/testing(those two are the only values treated as non-prod). - [ ]
SM_SECRET_KEYis a strong random value (not the default). - [ ]
SM_DATABASE_URLpoints at Postgres (postgresql+asyncpg://), not SQLite. - [ ]
alembic upgrade headrun against the production DB. - [ ] Frontend built (
npm run build→static/dist/) and bundled into the image — outsidedevelopment/testing, the app renders assets from the Vite manifest, not a dev server (see Static assets). - [ ] App boot in a non-development
SM_ENVIRONMENTstarts clean. Module discovery runs strict (missing/invalidmetaand entry-point failures raise), and the migration check (SM010) aborts boot — so a successful start covers those. The full page/locale/auth-provider diagnostic suite only runs in development, so do a clean dev boot before shipping (see diagnostic codes). - [ ] Admin bootstrap complete — an admin user exists and can log in.
- [ ] Reverse proxy forwards
X-Forwarded-Proto/X-Forwarded-For; configured with--proxy-headers. - [ ] HTTPS in front of the app (the session cookie is
HttpOnly/SameSite=Laxbut notSecureby default — terminate TLS at the proxy). - [ ] Log destination configured (
SM_LOG_FORMAT=json+ log shipping).
Build
Typical Docker build for an app scaffolded by smpy new:
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev
COPY . .
RUN uv run python -m compileall .
# Build the frontend
FROM node:20-slim AS frontend
WORKDIR /app/client_app
COPY client_app/package.json client_app/package-lock.json ./
RUN npm ci
COPY client_app .
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app /app
# Vite builds to ../static/dist relative to client_app, i.e. /app/static/dist
COPY --from=frontend /app/static/dist /app/static/dist
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]Tune worker count with --workers N for multi-CPU boxes, or run behind a process manager like Gunicorn with Uvicorn workers. A single worker is CPU-bound (one process, the GIL) — multiple workers scale read throughput roughly linearly on a multi-core box.
Size the DB pool to the worker count. Each worker keeps its own connection pool of up to SM_DB_POOL_SIZE + SM_DB_MAX_OVERFLOW (default 10 + 20 = 30) connections, so the deployment's ceiling is workers × (pool_size + max_overflow). Keep that under the database's max_connections (Postgres default 100) or workers will throw asyncpg.TooManyConnectionsError: sorry, too many clients already under load. For example, 4 workers want roughly SM_DB_POOL_SIZE=5, SM_DB_MAX_OVERFLOW=10 (≤ 60 connections). For larger fleets, put PgBouncer in front instead of growing every pool.
Running migrations on deploy
Don't migrate from inside the web container's startup hook — that way lies races when scaling up. Run it as a one-shot job before rolling the web tier:
# one-shot container — run from the host project (default workspace scaffold)
cd host && uv run alembic upgrade head
# then roll web tier
kubectl rollout restart deployment/webThe boot-time migration check (SM010) will fail cleanly if web starts against an unmigrated DB.
Process topology
For small deployments, one web container is plenty. Scale by adding:
- Web replicas — stateless; they share state via DB and Redis. Session cookies are signed, so any replica can serve any request.
- Celery workers —
SM_MODULES_ENABLED=background_tasks,<modules with tasks>keeps workers lean. Runuv run celery -A scripts.run_worker:celery worker -l info. - Celery beat (scheduler) — exactly one instance, separate deployment.
Observability
Logs
SM_LOG_FORMAT=json emits structured logs. Every line has a correlation_id (set by CorrelationIdMiddleware). Shipping to a central store (Loki / CloudWatch / Datadog) lets you trace a request across web and worker.
Health checks
/health— returns 200 with{"status": "healthy", "migration": ...}(includes the cached migration state). No dependency checks./health/live— Kubernetes liveness. Returns 200{"status": "alive"}if the process is up./health/ready— readiness. Runs every module's registered health checks concurrently and aggregates the worst status into the JSON body (healthy/degraded/unhealthy). Returns 200 regardless — inspect thestatusfield rather than the HTTP code.
Metrics
Not built in. The framework doesn't expose Prometheus metrics out of the box. If you need them, wrap uvicorn with prometheus-fastapi-instrumentator in your host bootstrap — it auto-scrapes endpoint latency/count.
Session cookie
SessionMiddleware uses SM_SECRET_KEY for signing. Starlette's defaults apply: HttpOnly + SameSite=Lax, but not Secure (the framework adds the middleware with only secret_key, so the cookie isn't restricted to HTTPS — rely on your TLS-terminating proxy). Rotate the secret by:
- Deploying with a new
SM_SECRET_KEY. - Expected behavior: existing sessions become invalid. Users sign back in.
There's no built-in key-rollover mechanism — if you need zero-downtime session rotation, fork SessionMiddleware to accept a list of keys (first = active, rest = verify-only).
CSRF
Relies on SameSite=Lax. Browsers don't attach the cookie to cross-site POST/PUT/DELETE, so forged submissions land unauthenticated and get rejected at the permission check. No token middleware, no per-form token.
Caveat: SameSite=Lax does attach on top-level navigations. If you have state-changing GETs (you shouldn't — but), that's an attack surface. Keep side effects out of GET handlers.
Reverse proxy
Minimum nginx:
upstream app { server app:8000; }
server {
listen 443 ssl http2;
server_name your-domain.com;
# TLS config here
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
}Start uvicorn with --proxy-headers so it trusts X-Forwarded-For for client IP logging.
Static assets
The Vite build outputs to static/dist/ (the app mounts the static/ dir at /static, so built assets live under /static/dist/). Serve these via the reverse proxy directly (not through Uvicorn) for better performance:
location /static/ {
alias /var/www/app/static/;
access_log off;
expires 1y;
add_header Cache-Control "public, immutable";
}Vite emits filenames with content hashes, so long cache TTLs are safe.
Production rendering & cache headers
Outside development/testing (i.e. any other SM_ENVIRONMENT), the app serves the built frontend instead of the Vite dev server:
- Manifest-based Inertia rendering. In dev/testing the page loads
main.tsxfrom the Vite dev server; otherwise the app reads the built Vite manifest and emits content-hashed<script>/<link>tags. At boot it normalises the raw Vite manifest (static/dist/.vite/manifest.json) intostatic/dist/.vite/inertia-manifest.json, re-keying the entry chunk to the path fastapi-inertia expects. If the build directory is read-only (e.g. an immutable container layer) it writes that normalised copy to a temp file instead. If no built manifest is found it logs a warning and leaves assets unconfigured rather than crashing — so a missingnpm run buildsurfaces as broken asset URLs, not a boot failure. Build the frontend before serving production traffic. - Immutable cache headers, even through Uvicorn. The app mounts
static/with aStaticFilessubclass that setsCache-Control: public, max-age=31536000, immutableon anything under/static/dist/assets/(Vite content-hashes those filenames, so the bytes for a URL never change). Non-hashed paths keep the defaultETag/Last-Modified. Fronting/static/with nginx (above) is still recommended for throughput, but the immutable headers are correct even when assets are served straight from the app.
Zero-downtime deploys
- Build new image.
- Run
alembic upgrade headas a one-shot job. kubectl rollout restart deployment/web(rolling).- Watch logs for
SM010(would mean step 2 failed silently). - Rollback path:
kubectl rollout undo; old pods come back up against the new DB. Make sure your migrations are backward-compatible for at least one release cycle (expand/contract pattern — see below).
Expand / contract migrations
- Add a new column nullable in release N.
- Release N+1: start writing to the new column, still read from both.
- Release N+2: stop reading the old column; make the new one NOT NULL.
- Release N+3: drop the old column.
Each release is deployable on its own. Each rollback goes to a compatible previous release.
Disaster recovery
- DB backups — Postgres point-in-time recovery via WAL archiving. Standard RDS / Cloud SQL options cover this.
- Secrets —
SM_SECRET_KEYmust be recoverable from your secrets manager. Rotating it invalidates all sessions but doesn't break anything else. - Sessions — lost sessions mean users re-login; acceptable.
- Uploads — the
file_storagemodule'sfilesystembackend is ephemeral. For production, point it at S3 or a network filesystem and back that up independently.
Publishing releases
See docs/release.md for the full Python + JS publishing flow (OIDC Trusted Publishing on PyPI, NPM_TOKEN on npm, driven from GitHub Actions).