Skip to content

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)