System overview#
Ticket Board runs as four containers behind one reverse proxy on a single homelab host: Caddy, Next.js, FastAPI, and Postgres. Every request the system handles flows through the same authentication middleware, the same service layer, and the same audit emitter, regardless of whether the caller is a browser, a curl invocation, or an MCP client.
This page sketches that flow. The detailed entity schema lives in data model; auth details live in auth and identity; the event taxonomy lives in observability.
Deployment topology#
flowchart LR
subgraph clients[Clients]
H[Human<br/>browser]
B1[Bot via MCP]
B2[Bot via curl<br/>X-API-Key]
WH[Webhook<br/>receivers]
end
subgraph host[Homelab host]
Caddy[Caddy<br/>:80 / :443]
subgraph compose[Docker Compose]
FE[Next.js<br/>:3000]
BE[FastAPI<br/>:8000]
DB[(Postgres 16<br/>:5432)]
end
end
H -->|cookie| Caddy
B1 -->|MCP path token| Caddy
B2 -->|X-API-Key| Caddy
Caddy -->|/api/v1/*| BE
Caddy -->|/mcp/bot/*| BE
Caddy -->|/healthz, /readyz| BE
Caddy -->|/*| FE
FE -->|server-side fetch| BE
BE --> DB
BE -.->|HMAC POST| WH
Everything terminates at one Caddy instance. The single-origin layout is not aesthetic — it is required for the session cookie's SameSite=Lax policy to work without a CORS dance. Caddy routes by path:
| Path | Upstream | Purpose |
|---|---|---|
/api/v1/* |
backend:8000 |
REST API |
/mcp/bot/{token}/ |
backend:8000 |
MCP transport — token is the credential |
/healthz |
backend:8000 |
Liveness probe (no DB) |
/readyz |
backend:8000 |
Readiness probe (DB ping) |
| Everything else | frontend:3000 |
Next.js App Router |
Caddy also redacts the /mcp/bot/{token}/... segment in its access logs before any line is written — the token in the path is a bearer credential. The matching redaction inside the FastAPI process is performed by structlog middleware. See Auth and identity for the threat model.
Request flow#
Every request, regardless of transport, passes through the same five concerns.
flowchart LR
REQ[Request] --> RID[request_id<br/>middleware]
RID --> AUTH[Auth middleware<br/>resolve CurrentActor]
AUTH --> ROUTE[Router / MCP tool dispatch]
ROUTE --> SVC[Service layer<br/>business rules + authz]
SVC --> REPO[Repository<br/>SQLAlchemy 2.0 async]
SVC --> EMIT[Event emitter]
REPO --> DB[(Postgres)]
EMIT --> AUD[(audit_event)]
EMIT --> WHQ[Webhook dispatch<br/>BackgroundTasks]
- Request correlation. A UUIDv7
request_idis generated and bound to the structured logger's context. It is later stamped ontoaudit_event.request_id,tool_invocation_log.request_id, and theX-Request-Idresponse header. One ID ties together every log line, every audit row, every webhook payload that the request produces. - Authentication. The middleware resolves the actor from one of three carriers — MCP path token,
X-API-Keyheader, or session cookie — and attaches a frozenCurrentActordataclass torequest.state. Downstream code reads from state; it never re-derives. See Auth and identity. - Routing. REST handlers and MCP tool handlers extract the actor and call into the service layer. Routers do not contain business logic.
- Service layer. Authorization decisions live here (project allowlist, read/write mode for bots). The service calls into repositories for I/O and emits events as it goes.
- Event emission. Every domain mutation produces an event. Events are queued onto the unit-of-work's after-commit hook; they are written to
audit_eventonly after the domain transaction commits. Auth failures bypass the unit of work and write immediately on a dedicated connection — the failure itself is the security signal. See Observability.
One service layer, two transports#
The system has two ingress shapes (REST and MCP) and one domain.
flowchart TB
subgraph ingress[Ingress]
REST[REST router<br/>/api/v1/*]
MCP[FastMCP mount<br/>/mcp/bot/{token}]
end
subgraph domain[Domain]
SVC[Service layer]
REPO[Repository layer]
end
REST --> SVC
MCP --> SVC
SVC --> REPO
A create_ticket MCP tool and a POST /api/v1/tickets REST endpoint both call ticket_service.create_ticket(actor, payload). The service is the only place that knows the business rules. The transport-specific code translates wire formats and nothing more.
This shape pays off in two ways:
- Authorization is uniform. "Is this actor allowed to do this?" is one function call, not two. Bots cannot escape a rule by switching transports.
- Activity attribution is uniform.
ticket.createdlooks the same in the audit log regardless of whether John clicked a button or a bot called a tool.
Process model#
One FastAPI process, one Postgres, one Next.js process. That's it.
| Process | Image | Notes |
|---|---|---|
caddy |
Official caddy:2 |
Reverse proxy, TLS termination in prod, access log redaction. |
backend |
Project image | FastAPI + Uvicorn + FastMCP mount + in-process webhook worker. |
frontend |
Project image | Next.js App Router, server components, fetches via Caddy. |
postgres |
Official postgres:16 |
One database; named volume for the data directory. |
The FastAPI process runs everything backend-side:
- The REST router (
/api/v1/*). - The FastMCP application mounted at
/mcp/bot/{token}(one mount, identity resolved per request from the URL). - The webhook dispatcher — a
BackgroundTasks-driven worker that fires HMAC-signed POSTs. - The retention sweeper for
session,idempotency_record, andtool_invocation_logrows. - The
/healthzand/readyzprobes.
There is no separate worker process, no Redis, no Celery. This is a deliberate choice. The trade-off is documented: webhook deliveries pending in-process are lost if the FastAPI process dies between enqueue and POST. The migration shape to a durable outbox is sketched in Observability so the choice doesn't lock anything in.
Storage#
Postgres 16 holds the entire system state:
- All domain entities (workspace, user, project, ticket, comment, wiki page, ...).
- The append-only
audit_eventtable that powers both the audit log and the activity feed. - Session and idempotency tables, swept on a schedule.
- The tool-invocation log for MCP bot calls.
- Webhook subscriptions and delivery records.
There is no separate cache layer. The Next.js frontend leans on TanStack Query for client-side caching; the backend leans on Postgres' shared buffers. A small render cache for wiki pages lives in a column on the page row itself — see Data model.
UUIDv7 primary keys mean inserts stay clustered at the B-tree leaf edge; this matters most for the hot append-only tables (audit_event, tool_invocation_log, webhook_delivery). Tickets carry a human-friendly display_id (PROJ-123) alongside their UUID — the display ID is what shows up in the UI and what bots cite when delegating. See Data model.
Simplifying choices and migration paths#
The system has four simplifying choices that are worth calling out:
| Choice | Reason | Migration shape if outgrown |
|---|---|---|
| In-process webhook delivery | One Postgres, one process. No external infrastructure to run. | Add a webhook_outbox table; replace BackgroundTasks with a poll loop. See Observability. |
| Server-side sessions in Postgres | Revocable in one DB write, observable in admin views, no header bloat. | Same model scales until JWT's stateless property becomes valuable. |
Single audit_event table for both audit and activity |
The split is presentation; the storage is the same. One emitter, one schema. | Partition by category if retention diverges. See ADR-0006. |
| In-memory rate limiter (per process) | Single Uvicorn worker; no shared store needed. | Move buckets to Redis if horizontally scaling. |
None of these are load-bearing for the v1 use case. All of them have a documented exit path that does not require touching the service layer.
What this overview does not cover#
For more depth on specific concerns:
- The schema, indexes, and soft-delete policy: Data model.
- Session cookie lifecycle, bot token format, the authorization matrix: Auth and identity.
- The full event taxonomy, webhook signature scheme, health endpoint contract: Observability.
- REST and MCP wire contracts: the API & MCP section.
- How to actually run this: Deployment.