Deployment topology¶
The same system as the overview, one zoom level down: actual containers, networks and ports on a host.
flowchart TB
subgraph OFF ["Off-box — survives the host being down"]
DOCS["docs.octopodia.com.au<br/>Cloudflare Pages"]
OBJ["Vultr AU object storage<br/>encrypted backups"]
UPTIME["External uptime check<br/>+ Sentry"]
end
NET(["Internet"]) -->|"80 → redirect + ACME<br/>443 → everything"| CADDY
subgraph HOST ["bos1 — the VPS"]
CADDY["Caddy<br/>reverse proxy + TLS"]
subgraph CORE ["Core stack — built once"]
CONSOLE["BOS Console<br/>operator only"]
BRAIN["The brain<br/>LangGraph engines"]
REG[("Registry Postgres<br/>the map, never client content")]
end
subgraph T1 ["Tenant stack — one per client"]
APP["Client review app<br/>approvals"]
TDB[("Tenant Postgres<br/>the spine")]
N8N["n8n instance"]
NDB[("n8n Postgres")]
MB["Metabase<br/>only if they bought it"]
end
end
CADDY --> CONSOLE
CADDY --> APP
CADDY -->|"/insights"| MB
CONSOLE --> REG
BRAIN -.->|"read-only"| REG
N8N -->|"internal network only"| BRAIN
BRAIN --> TDB
APP --> TDB
MB -.->|"read-only user<br/>serving views only"| TDB
N8N --> NDB
TDB -.-> OBJ
REG -.-> OBJ
UPTIME -.->|"checks from outside"| CADDY
The four things this diagram asserts¶
Caddy is the only door. Ports 80 and 443 are the entire public surface (ADR-0021 §5). n8n and every Postgres are internal-only — no DNS record, no firewall rule. External webhooks arrive through Caddy, never at n8n directly.
The registry is on its own network. Tenant containers cannot reach it. They talk to the brain; the brain reads the registry. This is what makes co-locating the control plane acceptable at this scale (ADR-0025 §2) — the condition of that decision, not optional hardening.
A tenant is a boundary, not a row. Each client gets their own Postgres and their own n8n instance on their own private network. Isolation is the container and volume boundary, so a query bug cannot cross tenants (ADR-0023).
Some things are deliberately not on the box. Documentation, backups and uptime checking all live elsewhere — each is needed precisely when the host is unavailable. An on-box monitor cannot tell you the box is down.
Hostnames¶
One A record per host; everything else is a CNAME to it. Rebuild the box and you edit one record.
| Name | Type | Points at | Who |
|---|---|---|---|
bos1.octopodia.com.au |
A | the IP | Ops only — SSH, inventory. Not proxied |
console.octopodia.com.au |
CNAME → bos1 |
the box | Operators. Console, plus /health |
*.app.octopodia.com.au |
CNAME → bos1 |
the box | Clients. Their app, plus /insights |
docs.octopodia.com.au |
CNAME | Cloudflare Pages | Us. Off-box on purpose |
A host is not a service. bos1 names the machine; clients only ever see {tenant}.app.…, which is independent of where it runs — so moving a tenant to dedicated hardware is an ansible_host change and a CNAME edit, invisible to them (ADR-0022).
Dedicated hosts are named {tenant}-bos1.
Scaling and its limits¶
Cost scales roughly linearly with tenants — a known input to the cost model, traded for isolation and n8n licence compliance.
Metabase is conditional. ~2 GB of JVM per instance, deployed only for clients who bought the dashboard product. Dashboard clients, not automation clients, are what strain the box.
The control plane moves off this host at the first compliance-bound client, the first dedicated tenant, or when the host is resized rather than extended (ADR-0025 §3).
More than one host¶
Everything above describes a single box. The moment there is a second, the components split by scope — not by "core versus tenant" (ADR-0030).
flowchart TB
subgraph CP ["Control plane — exactly one, anywhere"]
REG[("Registry")]
CON["BOS Console"]
end
subgraph B1 ["bos1"]
C1["Caddy"]
BR1["Brain replica"]
T1["tenants …"]
C1 --> BR1 --> T1
end
subgraph B2 ["greenhills-bos1"]
C2["Caddy"]
BR2["Brain replica"]
T2["one tenant"]
C2 --> BR2 --> T2
end
BR1 -.->|"read-only,<br/>private network"| REG
BR2 -.->|"read-only,<br/>private network"| REG
| Component | Second host needs its own? | Why |
|---|---|---|
| Caddy | Yes | Traffic arrives at a box; TLS terminates there |
| Brain | Yes — a replica | Stateless, so copies are cheap and no call crosses the network |
| Registry | No — exactly one, ever | Two maps is two truths, and reconciliation cannot arbitrate |
| BOS Console | No | One operator surface over the one registry |
The brain is one image, one pinned version, one role — Ansible deploys it to every host in inventory, so there is no second thing to maintain. The real cost is version skew: deploy the brain to all hosts in one run, never one at a time, and have the reconciliation pass report the version per host.
Replicas are only safe while the brain holds nothing that outlives a request — config comes from the registry, and LangGraph checkpoints go to the tenant's Postgres, never to local disk. Break that and the replicas diverge.
Two prerequisites before a second host exists
The control plane moves to its own host — a dedicated-box tenant is already a listed trigger (ADR-0025 §3) — and private networking is in place. Brain-to-registry is a Docker network on one box and a real network hop across two; it must never traverse the public internet.
Building it¶
See the VPS setup runbook for the step-by-step, and the provisioning build order for which Ansible role owns which decision.