The System at a Glance
Published Jul 4, 2026
Praxis924 does two very different kinds of work. Most requests are ordinary and fast: a learner opens /learn, reads a lesson, submits an exercise. But generating a lesson is neither fast nor cheap — it is a multi-minute chain of LLM calls that can fail halfway through when a provider hits its quota. Those two workloads have to coexist without the slow one dragging down the fast one, and without a mid-generation crash leaving a lesson stuck forever. The design that solves this is deliberately small: four containers, one Compose file, and one clever reuse of Postgres.
The problem
The naive version puts lesson generation directly in the request handler. An admin clicks Generate, the HTTP request blocks while the backend makes a dozen sequential LLM calls, and two minutes later — maybe — a response comes back. That fails in every interesting way. The request times out behind Nginx. A provider returns 429 on call nine of twelve and the whole thing dies, leaving the lesson half-written with no way to resume. The web worker is pinned for minutes, so ordinary reads queue behind it.
The usual fix is to bolt on a separate job queue — Celery plus a broker like RabbitMQ or Redis — and now you are running and monitoring a fifth and sixth moving part just to generate some text.
The design
Praxis924 keeps the topology flat. The whole stack is four services in one docker-compose.yml, and the same file runs in dev and in production on a single EC2 box.
1services:
2 db: # pgvector/pgvector:pg16 — relational data + embeddings + workflow state
3 volumes: [pgdata:/var/lib/postgresql/data]
4 redis: # LLM response cache
5 backend: # FastAPI — API routes AND durable workflows in one process
6 depends_on: [db, redis]
7 frontend: # Next.js 15 App Router
8 depends_on: [backend]The request path is a straight line. The browser talks to Next.js, which serves the UI and forwards data calls to FastAPI. FastAPI is layered — thin routers in api/v1 call services/ for logic and repositories/ for DB access — and reads and writes Postgres, checking Redis first for a cached LLM response. That is the fast path, and it stays fast because the slow path never runs inside it.
The slow path is where the interesting move is. Lesson generation runs as a DBOS durable workflow inside the same backend process — no separate worker fleet — and DBOS persists each step's state to the same Postgres. Clicking Generate enqueues a workflow and returns immediately; generation_status moves pending → generating, and the admin console polls for the result.
1@DBOS.workflow()
2def generate_lesson(lesson_id: int):
3 source = build_grounded_source(lesson_id) # top-K pgvector chunks, else raw content
4 for kind in ("main_explanation", "practical_explanation",
5 "key_principles", "summary"):
6 section = generate_section(kind, source) # each step checkpointed to Postgres
7 while section.score < 7 and section.rounds < 2:
8 section = improve_section(section) # review loop
9 persist(section)
10 generate_exercises(lesson_id, source)
11 mark_status(lesson_id, "generated")Because every @DBOS.step is checkpointed, a crash — an OOM, a redeploy, a provider outage that raises LLMUnavailableError — resumes from the last completed step instead of restarting from scratch. The lesson stays re-runnable rather than half-baked. You get the durability of a job queue without adding a broker, because the database you already run is the queue.
The gotchas
Reusing Postgres for three jobs and running everything in one Compose file is efficient, but it has sharp edges.
| Trap | Fix |
|---|---|
Nginx proxy_pass with a trailing slash rewrites /api/v1/* → /v1/*; every API call 404s | proxy_pass http://127.0.0.1:8000; — no trailing slash |
docker compose down -v drops the pgdata volume — wipes lessons and workflow state | Never on the server; it is a dev-only reset |
Grounded source re-sent on every LLM call blows tokens-per-minute | Cap it at ~3000 chars |
| Groq sits behind Cloudflare and rejects header-less calls | Send a User-Agent or get 403 "error code 1010" |
| Altering an existing table won't apply on startup | create_all only makes new tables; changes need an Alembic migration |
Warning: The DB is the crown jewels three times over — relational rows, pgvector embeddings, and DBOS workflow checkpoints all live on one EBS volume. There is no Terraform safety net. Any AWS console action that detaches or reformats that volume is unrecoverable.
Build it yourself
You need surprisingly little to reproduce the core.
1# 1. Four services, one file
2docker compose up -d --build # db (pgvector), redis, backend (FastAPI), frontend (Next.js)
3# 2. Enable the vector extension in the DB lifespan, before create_all
4# 3. Wrap the long job in a DBOS workflow pointed at the same Postgres
5# 4. Return immediately from the Generate route; poll generation_statusThe minimal loop: a FastAPI route that enqueues a DBOS workflow, a workflow that writes checkpoints to Postgres, and a status field the UI polls. Skip Redis at first — it is a cache, not a dependency. Skip the review loop; generate one section and persist it. Everything else is elaboration on this spine.
What changes at scale
The single-box design holds a long way, but a few seams are worth knowing. DBOS lets you run backend replicas that share the workflow queue in Postgres, so you can scale generation horizontally without introducing a broker. Once the LLM cache or session load grows, Redis moves to a managed instance. The database itself is the eventual bottleneck — when relational load, vector search, and workflow polling start competing, you split the data EBS volume onto a managed Postgres (RDS + pgvector) and let the box stay stateless. And the manual, console-provisioned infra becomes real IaC the moment a second environment exists. None of that is needed on day one; the whole point of four containers and one file is that it stays legible until it genuinely has to grow.