A full-stack starter with a FastAPI + PostgreSQL backend and a Next.js 16 (App Router) frontend, wired together with Docker Compose and Traefik.
- Backend (
backend/) — FastAPI, SQLModel, PostgreSQL, Alembic, JWT auth, and an AI chat built on LangChain Deep Agents (OpenAI, streamed over AG-UI and SSE, conversations kept in Postgres). - Frontend (
frontend/) — Next.js 16 (App Router), React, TypeScript, Tailwind CSS, shadcn/ui. - Infra (
infra/) — Docker Compose + Traefik, Mailpit for local email, Playwright for end-to-end tests.
From the project root, run:
./up.shThis resets and rebuilds the stack, runs migrations, and starts it with hot reload. The app is served through the Traefik proxy at http://localhost; the script prints the login to use.
Local URLs: API docs http://localhost/docs · Adminer http://localhost:8080 · Mailpit http://localhost:8025 · Traefik dashboard http://localhost:8090.
To iterate on one side directly instead, run uv run fastapi dev (from backend/) or bun run dev (from frontend/) against the Compose Postgres.
All settings live in backend/.env. Change SECRET_KEY (at least 32 characters), FIRST_SUPERUSER_PASSWORD, POSTGRES_PASSWORD (the database administrator) and APP_DB_PASSWORD (the limited app role the backend connects as) before deploying anywhere; outside development a short or placeholder value stops the backend from starting. The dbsetup service creates or updates the app role and makes it the owner of the app database on every start, so the backend never connects as the superuser and an existing database volume is converted the first time it runs. After AUTH_MAX_FAILURES (5) wrong passwords within AUTH_LOCK_MINUTES (15) an account answers 429 for that long, whatever the client address; a correct password or a password reset clears the count. The API docs are served only in development. Set OPENAI_API_KEY to turn the AI chat on; each user can send CHAT_RUNS_PER_HOUR (30) messages an hour, and the conversations are private to the user who started them.
The backend's Pydantic models are the source of truth. After changing them (or any route), regenerate the OpenAPI spec and the typed frontend client:
bash scripts/generate-client.shThis rewrites backend/openapi.json and frontend/src/client/ (a typed SDK, TypeScript types and Zod schemas). The frontend calls the API only through frontend/src/lib/api.ts, a thin adapter over the generated SDK, so URLs, methods and payload types are never typed by hand. The backend test suite and the pre-commit hook fail when the committed files are stale, CI type-checks and builds the frontend, and pull requests that change backend/openapi.json are checked for breaking changes (add the breaking-api-change label to accept an intentional one).
./up.sh and bun install (in frontend/) install the git hooks (prek, configured in .pre-commit-config.yaml) when they are missing; bash scripts/install-hooks.sh does it on its own. They run the same checks as the pre-commit workflow: formatting, spelling, Biome, ruff, mypy, ty, a fresh API client and the skill links. Run them on demand with uv run prek run --all-files.
The FastAPI and SQLModel packages ship agent skills. backend/.agents/skills and backend/.claude/skills link to them inside backend/.venv, so the links resolve once uv sync has run in backend/. After a dependency or Python version change, refresh them from backend/ with uv run --project .. library-skills --claude --yes; the pre-commit hook runs the same tool with --check.
- Backend:
bash scripts/test.sh(from the project root); it fails below 90% coverage, and also runs the migration tests against a throwaway database. - Frontend unit tests:
bun run test:unit(fromfrontend/); they fail below 90% coverage of the modules they load. - Frontend end-to-end:
docker compose run --rm playwright bunx playwright test.
Production runs behind Traefik with automatic HTTPS (Let's Encrypt) via infra/docker-compose.deploy.yml. Set DOMAIN and the secrets as environment variables on the host (or pass a file with --env-file); backend/.env.example lists every setting, the deployment-only ones commented out. Then:
docker compose -f infra/docker-compose.yml -f infra/docker-compose.deploy.yml up -dAdminer is part of the dev stack only and is never deployed. Traefik sends HSTS on every HTTPS response.
Database migrations run automatically, in dev and in production alike: the prestart service runs backend/scripts/prestart.sh (wait for the database, alembic upgrade head, seed the first superuser), and the backend starts only after it succeeds. up -d replaces the running backend before the migration runs, so if a migration fails the backend stays down until you fix it. To keep the current version serving when a migration fails, run the migration first, so a failure stops the deploy before anything is replaced:
docker compose -f infra/docker-compose.yml -f infra/docker-compose.deploy.yml run --rm prestartThen run the up -d command above. In dev, run ./up.sh again after adding a migration so the image is rebuilt with it.
The stack works without one. If you add a CDN or load balancer, three things must hold, and bun run check:headers checks the second one:
- Client address. Set
TRUSTED_PROXIESto the CDN's published address ranges (comma-separated CIDRs) andPROXY_HOPSto the number of proxies in front of Traefik (1 for a single CDN). Traefik then takes the client address for the rate limit fromX-Forwarded-For. Keep the ranges current: a request from an address that is not listed loses its forwarded address, and everyone arriving through that edge shares one rate-limit budget. Let the origin accept the CDN only, because a client that connects directly shares one budget with every other direct client. The per-account lockout does not depend on any of this. - Caching. The landing page (
/) is static and the same for everyone, so a CDN may cache it: it is sentpublic, max-age=0, s-maxage=300with no cookie, and its Content-Security-Policy allows inline scripts, because a cached page cannot carry a nonce. Every other page depends on the session cookie and carries a nonce that is new for every response, so it isno-store. Cache/and the hashed static files (immutable) only; never turn on a "cache everything" rule for the rest, which would serve one visitor's page to another with a nonce that does not match. After a deploy a cached landing page can point at files that no longer exist for up to five minutes, so purge/when you deploy. Leave Next'sCache-Controlheaders alone, forward therscheader and keep the query string in the cache key (Next's_rscparameter). - Check. From
frontend/, runbun run check:headers https://your-domain. It verifies that the landing page is cacheable, that every other page isno-storewith its own nonce, and that static files areimmutable. CI runs the same check against every production build.