Skip to content

ADR-0022: Edge routing, TLS and hostname scheme

  • Status: Accepted. §1 and §2 superseded in part by ADR-0040 (the console route goes through a Cloudflare Tunnel, not Caddy)
  • Date: 2026-08-17
  • Deciders: El, Lachlan
  • Supersedes: ADR-0014 §4

Context

ADR-0014 §4 settled Caddy and wildcard TLS but not the hostname pattern. Hostnames are close to permanent once issued: they end up in certificates, Caddy configuration, client bookmarks, links inside Teams threads, and service agreements. Deciding the pattern after the first tenant exists means living with an inconsistency or breaking links.

Decision

  1. Caddy is the reverse proxy and TLS terminator, part of the base box, routing by hostname. Wildcard certificate via the Cloudflare DNS challenge.
  2. Pattern: tenant first, then service, giving {tenant}.{service}.octopodia.com.au. One wildcard on *.app.octopodia.com.au covers every client, so adding a tenant is a DNS record and an Ansible variable. Service-first (app.{tenant}…) fragments certificates per client.
Surface Hostname
BOS Console console.octopodia.com.au
Documentation docs.octopodia.com.au (ADR-0028)
Client review app {tenant}.app.octopodia.com.au
n8n, Postgres no public DNS, ever

Tenant subdomains stay nested under a service label so they cannot clash with the marketing site or the Internal Portal. 3. Metabase is served under the app, not its own hostname: {tenant}.app.octopodia.com.au/insights, proxied by Caddy to that tenant's container. One hostname, one certificate, one bookmark, and no second login. Metabase's site URL is templated for the subpath. 4. portal is reserved for the Internal Portal on our domain. Client-facing is app, operator-facing is console. Two systems called "portal" is how a message ends up meaning the wrong one. On a client's own domain, portal. is fine, because it is their vocabulary. 5. Client vanity domains are CNAMEs, never redirects. portal.client.com.au points to {tenant}.app.octopodia.com.au. A redirect bounces the browser and the address bar reverts to our domain, which defeats the purpose. With a CNAME the browser stays on their name, and Caddy issues a certificate for the hostname automatically. Caddy configuration first, DNS second: the hostname must exist in Caddy before the CNAME resolves, or the certificate request fails. 6. A vanity domain is additional, never a replacement. We always hold a working name per tenant that does not depend on the client's registrar. 7. Tenant slug is immutable. It appears in DNS, certificates, database names, vault IDs and directory paths. Lowercase, alphanumeric. It does not change if the client rebrands. 8. DNS record creation is automated, and deletion never is (ADR-0024). Creation is additive and namespaced, and a wrong record affects one tenant that no one is yet using. Deletion presents to a client as total system outage and persists in resolver caches after the mistake is fixed. 9. The Cloudflare API token used by Ansible is scoped to DNS edit on the specific zone, never the global API key. A global key in a playbook can perform the deletion this ADR refuses to automate.

Consequences

Adding a tenant is a DNS record plus an Ansible variable, with no certificate work. Clients have one address and one login regardless of how many surfaces sit behind it.

Accepting vanity domains places the client's DNS in our uptime path: a registrar outage or a deleted record takes their portal down and generates a call to us. The service agreement must name the Octopodia address as the supported one.

Serving Metabase on a subpath costs some Caddy and Metabase configuration, and removes an entire login and navigation problem.

Alternatives considered

  • Service-first hostnames are rejected: they fragment certificates per client for no benefit.
  • Metabase on its own subdomain is rejected: a second address and login for the client, when the app already authenticates them and can sign the embed (ADR-0023).
  • An HTTP redirect for vanity domains is rejected: the client's branding is lost at the moment they arrive.
  • A vanity domain as the only address is rejected: we must hold a name that does not depend on a third party's DNS.