A modern full-stack AI-powered chat application built with FastAPI, Next.js, PostgreSQL, and Docker.
The project consists of a Python backend exposing REST APIs and AI integrations, and a React frontend built with Next.js. The application is fully containerized using Docker Compose, making it easy to develop, test, and deploy consistently across environments.
- FastAPI backend
- Next.js frontend
- PostgreSQL database
- SQLAlchemy Async ORM
- Alembic database migrations
- JWT authentication in two flows — a long-lived link, and a single-use claim exchanged for a rotating refresh token and short-lived access token
- Per-grant query quota, enforced by an atomic UPDATE rather than in memory
- OpenAI integration
- Dockerized development environment
- TypeScript frontend
- Tailwind CSS UI
- TanStack Query for API communication
- Python 3.13
- FastAPI
- SQLAlchemy 2.x (Async)
- Alembic
- PostgreSQL
- psycopg 3 (binary) — the driver, via
postgresql+psycopg:// - OpenAI SDK
- uv package manager
- mypy (typed, checked — see
backend/README.md)
- Next.js 16
- React 19
- TypeScript
- Tailwind CSS 4
- TanStack Query
- shadcn/ui
- React Markdown
- JWT Decode
- Docker
- Docker Compose
.
├── backend
│ ├── alembic
│ ├── alembic.ini
│ ├── app
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── __init__.py
│ ├── pyproject.toml
│ ├── README.md
│ ├── resources # CV markdown; system-prompt.md + contact.md gitignored
│ └── uv.lock
├── caddy
│ └── Dockerfile # Caddy + rate-limit module, built via xcaddy
├── Caddyfile # prod only; site address comes from $SITE_DOMAIN
├── docker-compose.yaml # dev
├── docker-compose.prod.yaml # production (Caddy + services)
├── docs
│ ├── architecture.md
│ ├── database.md
│ ├── deployment.md
│ └── development.md
├── scripts # run from cron on the production host
│ ├── backup-db.sh
│ ├── backup-private-resources.sh
│ ├── pull-backups.sh
│ ├── purge-chat-content.sh
│ ├── purge-expired-sessions.sh
│ └── setup-deploy-secrets.sh # CI deploy key + GitHub secrets; also key rotation
├── frontend
│ ├── components.json
│ ├── Dockerfile
│ ├── eslint.config.mjs
│ ├── next.config.ts
│ ├── package.json
│ ├── package-lock.json
│ ├── postcss.config.mjs
│ ├── public
│ ├── README.md
│ ├── src
│ └── tsconfig.json
├── CHANGES.md
└── README.md
┌─────────────────────┐
│ Browser │
└──────────┬──────────┘
│ HTTPS
▼
Caddy (TLS)
│
┌──────────────┴──────────────┐
│ │
▼ ▼
Next.js Frontend FastAPI Backend (/api/*)
│
┌─────────────┴─────────────┐
▼ ▼
PostgreSQL OpenAI API
Before running the project, install:
- Docker
- Docker Compose
- Git
For local Python development outside Docker:
- Python 3.13
- uv
Clone the repository:
git clone https://github.com/buzzgreyday/workchat.git
cd workchatCreate the environment file. Both compose files read backend/.env and
interpolate ${POSTGRES_USER} and friends from a .env beside the compose
file, so the symlink is required in development too — without it every
docker compose command aborts before starting anything:
cp backend/.env.example backend/.env
ln -s backend/.env .envAdd your OPENAI_API_KEY to backend/.env, then create the system prompt —
the backend raises at import if it is missing or empty:
cp backend/resources/system-prompt.md.example backend/resources/system-prompt.mdBuild the containers:
docker compose buildStart all services:
docker compose up -dVerify that everything is running:
docker compose psView backend logs:
docker compose logs -f backendView frontend logs:
docker compose logs -f frontendStop all services:
docker compose downcd backend && uv run --env-file .env -m app.build_skillsThe application uses environment variables for configuration. Secrets are never
committed — .env files are gitignored, and only .env.example files are tracked.
In production the only real env file is backend/.env, read by three different
consumers:
| Consumer | How it reads the file |
|---|---|
| Backend outside Docker (local dev) | load_dotenv(BACKEND_DIR / ".env") in Settings.from_env() |
The backend and db containers |
env_file: ./backend/.env in both compose files |
Compose itself, for ${POSTGRES_USER} etc. in docker-compose.prod.yaml |
only ever reads a .env sitting next to the compose file |
The third consumer is why a repo-root .env also has to exist. Rather than
duplicating the values, symlink it so there is a single source of truth:
ln -s backend/.env .envWithout that symlink every docker compose command fails with
required variable POSTGRES_DB is missing a value, unless you pass
--env-file backend/.env by hand each time.
To set up a new deployment, copy backend/.env.production.example to
backend/.env, fill in real secrets, then create the symlink.
The frontend needs no env file of its own: the only variable it reads is
NEXT_PUBLIC_API_URL, supplied by environment: in docker-compose.yaml for
dev and baked in as a build arg in docker-compose.prod.yaml for production.
The backend is built with FastAPI and uses SQLAlchemy's asynchronous ORM together with PostgreSQL.
The Docker image:
- Uses Python 3.13 Slim
- Installs dependencies with uv
- Creates an isolated virtual environment
- Runs as a non-root user
Start only the backend:
docker compose up backendOpen a shell:
docker compose exec backend bashThe frontend is built with Next.js 16 and React 19.
The Docker image:
- Uses Node.js Alpine
- Installs dependencies
- Builds the production bundle
- Starts the Next.js server
- Exposes port 3000
Start only the frontend:
docker compose up frontendOpen a shell:
docker compose exec frontend shTry it on a phone on the same Wi-Fi:
scripts/dev-lan.sh # prints a claim link and a QR code to scan
scripts/dev-lan.sh --off # back to localhost onlyIt recreates the backend and frontend with docker-compose.lan.yaml, which
points the browser at this machine's LAN address instead of localhost and
adds that origin to the backend's CORS list. localhost:3000 keeps working
alongside it. Each run mints a fresh claim link; claims work once, so run it
again for a new one.
Edits hot-reload on the phone as they do on this machine, while the overlay is
on. Each run, --off included, recreates the frontend with a different API
address, so reload any page left open from before. One opened before --off
keeps showing its last state and stops updating.
The project uses PostgreSQL with Alembic for schema migrations.
Whenever SQLAlchemy models change. Not on a new deployment — the migrations
are committed and backend/entrypoint.sh already runs alembic upgrade head at
startup, so a fresh deploy needs nothing here:
docker compose exec backend alembic revision --autogenerate -m "migration description"Example:
docker compose exec backend alembic revision --autogenerate -m "add users and tokens"Always review generated migrations before applying them.
Migrations will be performed automatically when docker backend container is started, to set latest.
docker compose exec backend alembic upgrade headdocker compose exec backend alembic currentdocker compose exec backend alembic historyExecuted command in the "db" docker container.
-U for username, e.g. "postgres"
-d for database schema, e.g. "db"
docker compose exec db psql -U postgres -d dbList tables:
\dtExit PostgreSQL:
\qOnce the backend is running:
Swagger UI:
http://localhost:8000/docs
Redoc UI:
http://localhost:8000/redoc
OpenAPI Specification:
http://localhost:8000/openapi.json
Two token versions are live at once. Which one a request is using is decided by
the ver claim on the JWT, and its absence means version 1 — the tokens
already handed out cannot grow a claim they were never minted with, so silence
has to keep meaning v1 for as long as any of those links is still in an inbox.
Only the auth endpoints are versioned. /chat/stream stays where it
are and accept either kind of access token, because moving them under /v2 would
have left every v1 hirer holding a link to a frozen API.
v1 — the links already sent out. One long-lived JWT per grant, delivered as
?token=... and sent straight back as Authorization: Bearer. Its jti is
the tokens row. Nothing about this path has changed.
/admin/issue-token (version=1) -> ?token=<access JWT> -> POST /chat/stream
v2 — claim, then refresh. The link carries a single-use claim token, which the client exchanges for a short-lived access token and a rotating refresh token.
/admin/issue-token (version=2) -> ?claim=<claim JWT>
|
v
POST /v2/auth/claim {claim_token} -> {access_token, expires_in, ...}
+ Set-Cookie: cv_refresh=... (HttpOnly)
POST /v2/auth/refresh (cookie) -> a fresh pair; the one presented is retired
POST /chat/stream Bearer <access_token>
Properties worth knowing:
- The refresh token is only ever a cookie. HttpOnly, Secure outside dev,
SameSite=Strict, and never in a response body — so script on the page cannot
read it. The access token is in the body, because the client has to put it in
an
Authorizationheader, which is why it is short-lived. An XSS gets minutes of access, not a week of it. In production Caddy serves the frontend and proxies the API under/apion one origin, so this is a plain same-origin cookie. - The claim link is single use. The first exchange spends it; every later one is refused with 409 and logs a notice that this hirer needs a new link. A leaked URL — in a browser history, a mail thread, a photographed QR code — is therefore worth nothing once the hirer has opened it. The cost is that clearing site data or moving device needs a new link.
- Quota is per grant, not per token. Claiming and refreshing cost nothing; only a question spends a query.
- Nothing derived outlives its grant. Access and refresh expiries are both
clamped to
tokens.expires_at. - Rotation has a grace window. A refresh token re-presented within
REFRESH_ROTATION_GRACE_SECONDSof being rotated answers 409 and changes nothing — that is one client racing itself, which happens whenever several requests expire at once and each retries. Without the window those retries read as replays and cut the grant, logging the hirer out of the tab they were sitting in. The frontend also single-flights refreshes so it rarely comes up. - Past the window, a replay cuts the grant. Rotation retires the token it was given, so a late second use is either theft or a badly behaved client and nothing can tell which. Every session is revoked; with a single-use claim there is no self-serve way back, so a notice is logged for the operator.
- An access token dies with its session, rotation included. The call that rotates hands back a fresh one, so a client only ever carries the newest pair.
GET /session reports who a token belongs to and what is left of its quota,
without spending any of it. Unversioned, like the chat routes: a v1 ?token=
JWT and a v2 access token are both valid ways to be here and get the same
answer.
GET /session Authorization: Bearer <access token>
-> {"subject": "...", "version": 2, "expires_at": "...",
"usage": {"used": 0, "remaining": 5, "max": 5}, "session_id": "..."}
It exists because usage used to arrive only inside a chat response, so the one
number a hirer wants on arrival — how many questions they get — was the one thing
they had to spend a question to learn. An exhausted grant returns
remaining: 0 with a 200 rather than a 429: that is a state to display, and the
person with none left is exactly who needs telling. /chat/stream still answers 429
when one is actually attempted.
Revoking is POST /admin/tokens/{token_id}/revoke (admin key required). It stamps
tokens.revoked_at and cuts every session, which matters because the grant — not
the session — is what a claim link reaches: cutting sessions alone would leave
anyone holding the link able to open a new one. It works on v1 grants too, and is
idempotent.
| Variable | Default | What it sets |
|---|---|---|
ACCESS_TOKEN_TTL_SECONDS |
900 (15 min) |
v2 access token lifetime |
REFRESH_TOKEN_TTL_SECONDS |
604800 (7 days) |
v2 refresh token lifetime |
REFRESH_ROTATION_GRACE_SECONDS |
30 |
How long a rotated token still reads as a retry |
OWNER_NOTIFY_THROTTLE_SECONDS |
3600 |
Minimum gap between operator notices per grant |
REFRESH_COOKIE_NAME |
cv_refresh |
Name of the refresh cookie |
Operator notices go through app/services/notify.py, which logs today. Wiring it
to an inbox is a change to that one file.
Build containers:
docker compose buildRebuild backend:
docker compose build --no-cache backendRestart backend:
docker compose restart backendRestart frontend:
docker compose restart frontendRestart all services:
docker compose restartStop services:
docker compose stopRemove containers and volumes:
docker compose down -vView logs:
docker compose logs -fTypical development workflow:
- Update application code. Both trees are bind-mounted and both live-reload —
the frontend via
npm run dev, the backend viauvicorn --reload(dev compose only). No rebuild is needed for source edits. The backend watcher is scoped tobackend/app/, so changes underbackend/alembic/need adocker compose restart backend. - If SQLAlchemy models changed:
- Generate a migration —
docker compose exec backend alembic revision --autogenerate -m "…". - Review it. Autogenerate misses things like server defaults and renames.
- Apply it —
docker compose exec backend alembic upgrade head.
- Generate a migration —
- If you edited the tags on anything in
backend/resources/, regenerate the skills list —cd backend && uv run -m app.build_skills— and commit it. The search index needs nothing: it is rebuilt when the backend reloads. - Run the tests on the host —
cd backend && uv run pytest(see Testing). - Rebuild only when dependencies change —
docker compose build backendafterpyproject.toml/uv.lock, orfrontendafterpackage.json. - Commit code and migration files together, so a checkout never has models and schema out of step.
- Work on an
experimental/*branch and open a pull request intodevelopment.mainis production and takes merges fromdevelopmentonly — see Branches.
Run on the host, not in the container — the image is built with
uv sync --frozen --no-dev and pytest is in the dev dependency group, so it
is not installed inside either the dev or prod image:
cd backend && uv sync && uv run pytestLint the frontend:
docker compose exec frontend npm run lintdocker compose logs backendThe two most common causes are configuration, not code:
system prompt not found/is empty—backend/resources/system-prompt.mdis gitignored and read at import, so the process exits before serving.cp backend/resources/system-prompt.md.example backend/resources/system-prompt.md<VAR> not found or empty in environment variables—backend/.envis missing a required key. Compare it againstbackend/.env.example.
If docker compose itself aborts with required variable POSTGRES_DB is missing a value before any container starts, the root .env symlink is
missing: ln -s backend/.env .env.
Verify the database container is running:
docker compose psVerify the current migration:
docker compose exec backend alembic currentApply pending migrations:
docker compose exec backend alembic upgrade headBackend:
docker compose build --no-cache backendFrontend:
docker compose build --no-cache frontendCaddy handles TLS via Let's Encrypt and proxies /api/* to the backend,
everything else to the Next.js frontend. Alembic migrations run automatically
in the backend entrypoint, so a fresh database needs no manual step.
→ docs/deployment.md is the single source of truth
for deploying: prerequisites, the ordered first-deployment checklist, issuing
access tokens, logs and backups.
Possible future enhancements include:
-
AuthHandler — done:
Authinapp/services/auth.py -
Refresh token (via claim/invite id/token)— done, see Token flow -
Token persistence
-
Handle max queries reached, invalid/expired token, etc. in frontend— done; the allowance is shown fromGET /sessionon load, and a refused question says which kind of refusal it was -
ChatClient
-
Abstractions
-
Move the hardcoded conditional prompts from the code to separate files
-
Resource search scoring
-
CI/CD pipeline— done: GitHub Actions runs types, tests, migration drift, Caddyfile, secret scanning and image builds. Work happens onexperimental/*, integrates ondevelopmentand reaches production throughmain, where a green run queues a deploy for approval. See branches anddocs/deployment.md -
Automated testing— done: 100 backend tests, run in CI -
Background workers
-
Monitoring and metrics
-
Centralized logging
-
Backup automation
-
API versioning — partly done: the auth endpoints are under
/v2, the chat ones are deliberately unversioned so existing links keep working -
Chat client polymorph and factory (YAGNI)