# Deployment topologies — one binary, three blessed shapes

The FOB never decomposes; topology is a configuration property, not a code property. The same
binary runs in every shape below; only `RUTSTER_*` env and what is deployed alongside it
differ. Ratified (Proposed) in [ADR-0011](../adr/0011-deployment-topology.md).

## Decision guide

| You are… | Use | Doc |
|---|---|---|
| Trying rutster for the first time; one box, one domain, a public IP | **T1 — Solo** | [quickstart-docker.md](quickstart-docker.md) |
| Running production and want independently upgradable parts | **T2 — Modular** (the reference deployment) | [quickstart-docker.md](quickstart-docker.md#t2--modular-compose-stack) |
| Behind CGNAT / no public IP (homelab) | T1/T2 + tunnel (dev, **PSTN-only**) or VPS forwarder (production) | [homelab.md](homelab.md) |
| Already running nginx / HAProxy / Traefik | T2 minus the `caddy` service | [reverse-proxies.md](reverse-proxies.md) |
| On EC2 and want ALB + ACM to terminate TLS | T2/T3 behind an ALB | [aws.md](aws.md) |
| Outgrowing one node | **T3 — Fleet** — **paper only today** | [ADR-0011](../adr/0011-deployment-topology.md) |

## T1 — Solo (all-in-one container)

One image, `rutster-allinone`, with s6-overlay supervising four processes:

| Process | Role | Binding |
|---|---|---|
| `caddy` | Edge: ACME issuance/renewal, TLS termination, WS proxy | `:443` / `:80` public |
| `rutster` (the FOB) | Engine — signaling, trunk webhook, media, reflexes | `127.0.0.1:8080` behind Caddy; **media UDP direct** |
| `rutster-brain-realtime` | Brain | `127.0.0.1:8082` loopback tap |
| `valkey-server` | Event stream (`EventSink`); ledger/directory later | `127.0.0.1:6379` |

Two volumes, **both non-negotiable**:

- `/data` — Caddy's cert/ACME state. Losing it can lock your domain out of Let's Encrypt for
  up to a week (duplicate-certificate limit) — a **total inbound outage**. See
  [certificates.md](certificates.md).
- `/var/lib/valkey` — stream/state persistence.

WebRTC media (UDP) goes **direct to the FOB, never through Caddy**: run with host networking,
or publish `RUTSTER_MEDIA_PORT_RANGE` and set `RUTSTER_MEDIA_ADVERTISED_IP` to your public IP
(NAT 1:1 model — no STUN).

Copy-paste path from zero to first call: [quickstart-docker.md](quickstart-docker.md).

## T2 — Modular (compose stack) — the reference deployment

Four services in `deploy/compose.yaml`:

| Service | Image | Notes |
|---|---|---|
| `caddy` | `rutster-edge` | Same custom Caddy build + same Caddyfile as T1 |
| `engine` | `rutster-engine` | The FOB; plaintext `:8080` on the compose network; media UDP published |
| `brain` | `rutster-brain` | `network_mode: "service:engine"` — shares the engine's netns so the loopback-only tap posture survives unchanged |
| `valkey` | `valkey/valkey` (upstream) | Own service; network-near |

Operational contract:

- `stop_grace_period: 660s` paired with `RUTSTER_DRAIN_DEADLINE_SECS=600` — grace **must**
  exceed drain, or the orchestrator kills calls the engine was gracefully draining.
- Bring-your-own-proxy: disable the `caddy` service; the engine keeps its plaintext `:8080`
  mode. Tuned-timeout configs: [reverse-proxies.md](reverse-proxies.md).
- Upgrades: `docker compose pull && docker compose up -d` — the drain window lets in-flight
  calls finish (calls are non-migratable; a killed engine kills its calls, honestly stated).

Copy-paste path from zero to first call:
[quickstart-docker.md](quickstart-docker.md#t2--modular-compose-stack).

## T3 — Fleet (paper only — no shipped artifacts in this epoch)

**Honest statement: you cannot deploy T3 today by copy-paste; there are no fleet artifacts
yet.** The design is ratified on paper in
[ADR-0011](../adr/0011-deployment-topology.md) so single-node deployments grow toward it, not
away from it:

- N symmetric nodes (the T1 process set minus valkey) + a shared green zone: one Valkey
  (presence + directory + spend ledger), object storage.
- Wildcard DNS `*.pbx.domain`; per-node certs — the traps live in
  [certificates.md](certificates.md).
- Answer-time placement via Valkey presence; node-addressed `wss://node-K.pbx.domain/...`
  Stream URLs; no router tier.
- Per-node admission cap = the benchmark number (`RUTSTER_MAX_SESSIONS`, default 64, is a
  placeholder until then).
- Scale-in is drain-then-terminate only. No spot instances for the engine tier.

## What is deliberately not supported

- **Splitting the FOB into services** — permanent rejection, not a roadmap item.
- **Serverless** — non-migratable multi-hour calls, per-session UDP sockets, in-process media
  state. Categorically unfit.
- **Tunnels in production** — plaintext audio at the tunnel vendor's edge + documented
  mid-call WS kills. Dev/demo only; the whole story is in [homelab.md](homelab.md).
- **NLB TLS listeners** — see [aws.md](aws.md) for the 350-second reason.
- **k8s manifests/Helm** — compose-first. If you run k8s anyway: set
  `terminationGracePeriodSeconds` above `RUTSTER_DRAIN_DEADLINE_SECS`, same grace-exceeds-drain
  rule as compose.

## Ports & volumes reference (all shapes)

| Surface | Where | Notes |
|---|---|---|
| `:80` / `:443` TCP | Caddy, public | ACME HTTP-01 + TLS + all HTTPS/WSS traffic |
| `:8080` TCP | FOB, behind the edge | Signaling + `/v1/trunk/webhook` + `/twilio/media-stream` WS — one listener (`RUTSTER_HTTP_BIND`) |
| `RUTSTER_MEDIA_PORT_RANGE` UDP | FOB, **public, direct** | WebRTC media; never proxied |
| `:8082` TCP | Brain, loopback only | The tap; non-loopback rejected until step 6 |
| `:6379` TCP | Valkey, private | Loopback (T1) / compose network (T2) / green zone (T3) |
| `:9090` TCP | FOB `/metrics`, internal | `RUTSTER_METRICS_BIND`; never routed through Caddy |
| `/data` volume | Caddy | Cert/ACME state — loss class: total inbound outage |
| `/var/lib/valkey` volume (T1) / `/data` volume (T2) | Valkey | Event stream persistence — T1 uses the valkey upstream image's default `/var/lib/valkey` (baked into the Dockerfile VOLUME); T2 compose mounts `/data` (upstream `valkey/valkey` image convention per deploy/compose.yaml — `--dir /data` flag) |
