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 → client surfaces"| CADDY
subgraph HOST ["bos1: the VPS"]
CADDY["Caddy<br/>reverse proxy + TLS<br/>clients only"]
CFD["cloudflared<br/>dials out, no inbound port"]
TSS["tailscale serve<br/>break-glass"]
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
OPS(["Operators"]) -->|"Cloudflare Access<br/>Entra SSO + MFA"| CFE["Cloudflare edge"]
OPS -.->|"break-glass:<br/>tailnet only"| TSS
CFD -->|"tunnel"| CFE
CFD --> CONSOLE
TSS --> 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 public door. Ports 80 and 443 are the entire public surface (ADR-0021 §5), and Caddy serves only what clients reach: the review app, vanity domains and /insights. n8n and every Postgres are internal-only, with no DNS record and no firewall rule. External webhooks arrive through Caddy, never at n8n directly.
The Console has no public listener at all (ADR-0040). cloudflared runs as its own container and dials out to Cloudflare, which puts Cloudflare Access (Entra SSO with MFA, El and Lachlan only) in front of console.octopodia.com.au. The Console checks Access's token on every request. Break-glass is the tailnet through tailscale serve, admins only, and is not enabled on any host carrying client data until the tailnet has least-privilege grants (OI-086).
The registry is on its own network. Tenant containers cannot reach it. They talk to the brain, and the brain reads the registry. This is what makes co-locating the control plane acceptable at this scale (ADR-0025 §2). It is 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, because 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, and 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 → the Cloudflare Tunnel | the box, through cloudflared |
Operators, behind Cloudflare Access. Console, plus /health. Not via Caddy (ADR-0040) |
*.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. That is a known input to the cost model, traded for isolation and n8n licence compliance.
Metabase is conditional. Around 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, and 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, and it must never traverse the public internet.
Building it¶
See the host setup runbook for the step-by-step, and the provisioning build order for which Ansible role owns which decision.