ADR-0014: VPS provisioning & server hardening baseline¶
- Status: Superseded by ADR-0021, ADR-0022, ADR-0023, ADR-0025, ADR-0026, ADR-0027 (2026-08-17)
- Date: 2026-07-25
- Deciders: El
Superseded. This ADR bundled nine unrelated decisions, so no single part could be revised without reopening all of it. It is retained for history; do not cite it as current. See
docs/DECISIONS.mdfor the section-by-section mapping. Substantive changes on supersession: backups are now tiered by recoverability with a retention floor on approval records (ADR-0026); secrets use a per-tenant--vault-idand a named shared credential store (ADR-0027); the control-plane split now has a written migration trigger (ADR-0025).
Context¶
Client-zero (Octopodia's own inbox, email triage) runs on a real AU host before the first paying client. The server design must be decided before provisioning, encoded entirely in Ansible (no hand-configured drift), and must uphold AU data residency and per-tenant isolation. This ADR records the baseline; it is realised as Ansible roles, not manual steps.
Decision¶
- Provider/topology: Vultr, Melbourne. One host to start, shared core and tenant stacks co-located, small instance (~4-8 GB), resize on demand. Control-plane/tenant split deferred.
- SSH: key-only auth, passwords disabled (keys verified before disabling), root SSH disabled, per-person users (el, lachlan) each with their own key, non-standard port (52843), fail2ban. Tailscale is a planned later step to move SSH onto a private mesh and close the public SSH port.
- Firewall: default-deny inbound; only the SSH port, 80, and 443 open. Everything else internal to the host and never internet-exposed. 80 exists only for TLS challenge + http->https.
- Caddy: reverse proxy + automatic TLS, part of the base box, routes by hostname; wildcard certificate for *..octopodia.com.au via the Cloudflare DNS challenge; tenant subdomains nested to avoid clashing with the marketing site/portal.
- Docker: separate Compose file per stack (core = one; each tenant = one, templated); per-tenant private networks plus a shared-core network; restart: unless-stopped; generous per-container resource limits.
- Postgres (tenant data): one instance per tenant (not shared with tenant_id rows); isolation is the container + volume boundary; principal column for row-level security in the schema from day one (enforced later); named volumes on main disk (block storage a later option); pgvector-enabled image, single pinned major version across tenants.
- n8n: one instance per tenant (licence-compliant as managed hosting on the client's behalf, not reselling n8n as a service; Projects feature not used); each n8n has its own dedicated Postgres; n8n calls the brain over the internal network only, tenant ID in every call; external webhooks arrive via Caddy, n8n never directly exposed.
- Backups: Vultr AU-resident object storage; registry and every tenant DB (business + n8n) daily, 2-month retention, encrypted at rest; a test restore at least every 6 months.
- Secrets: Ansible Vault; non-secret config and vault-encrypted secrets assembled together by Ansible and injected as env at runtime; vault key in the founders' password manager, supplied at deploy time; per-client OAuth tokens stored encrypted in n8n's own DB (see ADR-0010).
Consequences¶
The whole server is reproducible from Ansible; per-tenant n8n means infra cost scales roughly linearly (a known cost-model input, traded for isolation and licence compliance); Tailscale, block-storage migration, and RLS enforcement are deferred with hooks in place; the 6-monthly test restore must be a scheduled event or it silently never happens.
Alternatives considered¶
- Shared n8n with the Projects feature — rejected: licensing and shared credential boundary.
- Shared Postgres with tenant_id rows — rejected: weaker isolation (see ADR-0007).
- Kubernetes — rejected (see ADR-0007).
- Non-AU or non-Vultr region — rejected (see ADR-0008).