Skip to content

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.