Stack and sizing¶
What each component is, why it is here, and what it costs in resources. Use the sizing worksheet when provisioning a host.
These are planning figures, not measurements
Every number below is a starting estimate for sizing a box. The Metabase figure in particular is unverified: confirming it is OI-002, and it is the single largest line. Measure on the first host and correct this page.
The layers¶
flowchart TB
subgraph HOST ["Host: Ubuntu 24.04 LTS"]
direction TB
subgraph EDGE ["Edge"]
CADDY["Caddy<br/><i>clients</i>"]
CFD["cloudflared<br/><i>Console tunnel</i>"]
end
subgraph SHARED ["Core stack: once per host"]
BRAIN["LangGraph brain"]
CONSOLE["BOS Console"]
REGDB[("Registry Postgres")]
end
subgraph TENANT ["Tenant stack: once per client"]
APP["Client review app"]
TDB[("Tenant Postgres<br/>+ pgvector")]
N8N["n8n"]
NDB[("n8n Postgres")]
MB["Metabase<br/><i>optional</i>"]
end
end
Core stack: paid once per host¶
Two different scopes hide in here
The registry and Console exist exactly once globally, however many hosts you run. Caddy and the brain exist once per host. See ADR-0030: on a second box you add another Caddy and another brain replica, never another registry.
| Component | Scope | What it does | Why this one | RAM | Notes |
|---|---|---|---|---|---|
| Ubuntu 24.04 LTS | Per host | Operating system | Long support runway, and standard for Ansible and Docker | ~400 MB | Socket-activates SSH. See known traps §1 |
| Docker + Compose | Per host | Container runtime and per-stack orchestration | Isolation without an orchestration platform to operate. Not Kubernetes (ADR-0007) | ~150 MB | Daemon overhead, excluding containers |
| Tailscale | Per host | Private mesh carrying all administrative access | Removes the public SSH port entirely (ADR-0031) | ~30 MB | Disable node key expiry, or the host silently drops off the tailnet |
| Caddy | Per host | Reverse proxy, automatic TLS, hostname and path routing for client surfaces | Automatic certificates via the Cloudflare DNS challenge, and far less config than nginx | ~50 MB | The only public door: ports 80 and 443. Not the Console's route (ADR-0040) |
| cloudflared | With the Console | Cloudflare Tunnel for console.octopodia.com.au, behind Cloudflare Access |
Dials out, so the Console needs no inbound port (ADR-0040) | ~30 MB | Deployed by Ansible; tunnel token in the vault (core). Break-glass is the tailnet, via tailscale serve |
| LangGraph brain | Per host (replica) | The shared engines: Capture, Progress, Bill, Remember | Stateless and shared, so every tenant benefits from one change (ADR-0002) | ~750 MB | Python. Holds no state: config from the registry, checkpoints to tenant Postgres |
| Registry Postgres | Global, exactly one | Control plane: tenant records, resolved config, connector manifests | The map, never client content (ADR-0006) | ~256 MB | Small. Its own isolated network. Never run two |
| BOS Console | Global, exactly one | Operator surface: shows the fleet and changes it (ADR-0037). The only thing that edits control-plane config | Separate service from the Internal Portal. FastAPI, React with Vite and TypeScript | ~256 MB | Reached through the Cloudflare Tunnel, not Caddy (ADR-0040). Serves /health on a subpath |
Per host, if it carries the control plane: ~1.9 GB including the OS. A host without the control plane: ~1.4 GB, with no registry and no Console.
Tenant stack: paid per client¶
| Component | What it does | Why this one | RAM | Notes |
|---|---|---|---|---|
| Tenant Postgres | The client's spine: work items, approvals, landing, modelled and serving layers | One instance per tenant. Isolation is the container and volume boundary, not a tenant_id column (ADR-0023) |
~512 MB | pgvector image, pinned major version. Largest disk consumer |
| n8n | Connectors, credentials and triggers for that client's tools | One instance per tenant. Projects feature not used, on licence compliance grounds (ADR-0023 §4) | ~400 MB | Spikes during workflow execution |
| n8n Postgres | n8n's own state, including encrypted OAuth tokens | Kept separate from the client's business data | ~256 MB | Small and stable |
| Client review app | Approvals inbox and history, headline metrics | Reads its own tenant's data only, never the control plane | ~256 MB | Mostly static, light |
| Metabase | Self-serve dashboards at /insights |
Only for clients who bought the dashboard product (ADR-0023 §5) | ~2 GB | JVM. The dominant per-tenant cost |
Per tenant: ~1.4 GB without dashboards, ~3.4 GB with.
Optional, host-level¶
| Component | What it does | RAM | Notes |
|---|---|---|---|
| Langfuse | LLM tracing. Six containers: web, worker, ClickHouse, MinIO, Redis, Postgres | ~6 to 8 GB | One shared instance, project per tenant. Off by default. Not deployed for client-zero |
The largest single line on the box
Langfuse costs more than two entire automation tenants. It is a debugging convenience, not infrastructure: the durable operational record is Postgres, so losing it costs nothing permanent.
Per-tenant instances were considered and rejected on cost. See prompts: tracing for why shared is acceptable here when it was not for Metabase.
Sizing worksheet¶
Core stack (incl. OS) 1.9 GB
+ automation-only tenants x 1.4 GB
+ dashboard tenants x 3.4 GB
+ headroom (25%)
=========================================
= provision at least this much RAM
| Scenario | Calculation | Provision |
|---|---|---|
| Client-zero only | 1.9 + 1.4 + 25% | 4 GB |
| Client-zero plus Langfuse | 1.9 + 1.4 + 7 + 25% | 13 GB |
| 3 automation tenants | 1.9 + 4.2 + 25% | 8 GB |
| 3 automation + 1 dashboard | 1.9 + 4.2 + 3.4 + 25% | 12 GB |
| 5 automation + 2 dashboard | 1.9 + 7.0 + 6.8 + 25% | 20 GB |
Dashboard clients, not automation clients, drive the size. One Metabase costs more than two entire automation tenants, which is why it is conditional on the tenant's services list and priced into the dashboard product.
Langfuse roughly triples the client-zero box on its own, which is why it is off by default and deferred until there is something worth observing.
CPU¶
2 vCPU is enough to start. This is not a compute-bound workload: it waits on APIs and databases. Add cores when tenant count grows, not when latency feels slow. Latency is almost always an upstream API.
GPU¶
None. Ever, on this box. Inference is an API call to Anthropic or Gemini via packages/llm-client (ADR-0009). If self-hosted inference is ever adopted, it gets its own machine, never the host serving client data.
Disk¶
| Item | Estimate |
|---|---|
| OS, Docker, images | ~20 GB |
| Per tenant, first year | 5 to 20 GB |
| Landing layer | Grows and is never pruned. Kept permanently so metric redefinitions rebuild locally instead of re-pulling against the AroFlo API cap |
Start at 80 GB and monitor. Block storage is the later option, rather than resizing the instance.
Off-box, and deliberately so¶
| Component | What it does | Why off-box |
|---|---|---|
| Cloudflare Pages | This documentation site | A runbook must survive the outage it is used in (ADR-0028) |
| Cloudflare DNS and Access | Routing and identity | Independent of the host |
| Vultr AU object storage | Encrypted backups | A backup on the host it protects is not a backup (ADR-0026) |
| Tailscale coordination | Administrative access | Independent of the host, and the reason no public SSH port is needed (ADR-0031) |
| Sentry | Error alerting | Reports errors the box cannot report itself |
| External uptime check | Is it alive at all | An on-box monitor cannot tell you the box is down (OI-048) |
| Anthropic and Gemini | LLM inference | No model weights, no GPU, no inference on this host (ADR-0009) |
| Bitwarden | Human credentials | Availability independent of our infrastructure (ADR-0027) |