Skip to content

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]
  1. Request correlation. A UUIDv7 request_id is generated and bound to the structured logger's context. It is later stamped onto audit_event.request_id, tool_invocation_log.request_id, and the X-Request-Id response header. One ID ties together every log line, every audit row, every webhook payload that the request produces.
  2. Authentication. The middleware resolves the actor from one of three carriers — MCP path token, X-API-Key header, or session cookie — and attaches a frozen CurrentActor dataclass to request.state. Downstream code reads from state; it never re-derives. See Auth and identity.
  3. Routing. REST handlers and MCP tool handlers extract the actor and call into the service layer. Routers do not contain business logic.
  4. 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.
  5. 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_event only 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.created looks 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, and tool_invocation_log rows.
  • The /healthz and /readyz probes.

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_event table 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.