ADR-114: Shared multi-tenant public edge (host-level Caddy + Let's Encrypt)¶
- Status: Accepted
- Date: 2026-07-08
- Authors: Marko Dragoljevic, Claude (Opus 4.8)
- Related RFCs: RFC-082 (always-on hosting), RFC-087 (broader public-edge ambition — remains deferred; this ADR ships only the shared-engine slice)
- Related ADRs: ADR-083 (tailnet private ingress — unchanged), ADR-084 (compose topology)
- Cross-repo: orrery ADR-078 (tenant-side decision), issues #1158 (this repo — engine) / chipi/orrery#381 (first tenant)
- Security SSOT: Threat model — VPS + public edge — the living risk register + pre-public gate this decision is measured against
Context¶
The always-on Hetzner VPS (RFC-082)
is tailnet-only today: the Hetzner firewall opens 22/SSH (conditional) + 41641/udp
(Tailscale) + ICMP, and both podcast_scraper and orrery are served privately via
tailscale serve. ADR-083's private-ingress posture is unchanged for the admin/deploy
plane.
A public ingress is now needed. orrery is going public on its own registered domain (orrery ADR-078). Behind it, the operator will stand up at least three more public endpoints over the coming weeks:
| Tenant | Kind | Public host |
|---|---|---|
| orrery | website | its own registered domain (isolated) |
| gi/kg viewer | website | podcast-family domain (TBD) |
| podcast player | mobile app | stable HTTPS API host, podcast-family domain (TBD) |
| app #3 | mobile app | podcast-family domain (TBD, later) |
This repo owns the VPS's Terraform state and cloud-init, so it owns the shared public edge engine. orrery's ADR-078 is a tenant's view and an input — the engine is designed here, as a reusable N-tenant platform, not shaped around any single app.
The operator may move orrery to its own VPS later if budget allows. The design must let a tenant leave with zero blast radius on the others.
Decision¶
1. One shared host-level Caddy, per-app drop-in vhosts¶
A single vanilla Caddy process (official apt repo, no plugins) owns :80 (→ 301 https)
and :443 (TLS via Let's Encrypt / ACME — an open standard, self-hosted, swappable). It
routes by Host header to each app's container on host loopback. The load-bearing
multi-tenant mechanism is import /etc/caddy/sites/*.caddy: the engine is owned once here;
each app drops its own <app>.caddy into the sites dir. No repo ever edits another
repo's routing. Because Caddy routes purely on Host, the engine is topology-agnostic
— apex domains, subdomains, or a mix all work; each tenant brings its own hostname(s). The
podcast-family domain layout (one domain + subdomains, or two domains) is deliberately
not decided here and does not block the engine.
2. Ownership split (mirrors the tailscale-serve precedent)¶
| Piece | Owner | Lives in |
|---|---|---|
| Firewall: open 80 + 443 | infra | this repo — hcloud_firewall.main (infra/terraform/main.tf) |
| Caddy install + systemd unit | infra | this repo — infra/cloud-init/prod.user-data |
Base Caddyfile (ACME email + shared snippets + import) |
infra | this repo — cloud-init |
| Reusable snippets (HSTS, compression, access log) | infra | this repo — base Caddyfile |
Narrow sudoers: deploy@ may reload caddy |
infra | this repo — cloud-init |
| Host hardening (fail2ban on Caddy access log) | infra | this repo — cloud-init |
| ACME cert issuance + storage | Caddy (self-managed) | host data dir |
Per-app vhost <app>.caddy |
each app | that app's repo (e.g. orrery infra/caddy/orrery.caddy) |
Deploy: scp vhost → /etc/caddy/sites/ + validate + reload |
each app | that app's deploy workflow |
| DNS A record | operator | registrar |
3. Define shared policy once, apps import it¶
Rather than each app copy-pasting HSTS/headers (as the orrery draft does inline), the base Caddyfile defines reusable snippets and apps import them. One place to ramp HSTS or change logging platform-wide:
{
email <OPERATOR_EMAIL> # ACME account contact (expiry notices; not public)
}
(hardened) {
# Start short; ramp to max-age=31536000; includeSubDomains; preload once proven.
header Strict-Transport-Security "max-age=86400; includeSubDomains"
encode zstd gzip
log {
output file /var/log/caddy/access.log
}
}
import /etc/caddy/sites/*.caddy
A tenant vhost then reduces to its host block + import hardened + reverse_proxy
127.0.0.1:<port>. The upstream container remains the authority for CSP / cache-control;
Caddy passes those through untouched and only adds the shared edge policy.
4. Loopback port registry (this repo allocates)¶
Collision avoidance across N upstreams is infra's job. Reserved map:
| Port | Tenant |
|---|---|
| 8080 | podcast api (existing stack) |
| 8090 | orrery nginx (live) |
| 8091 | gi/kg viewer |
| 8092 | podcast player API |
| 8093 | app #3 (reserved) |
5. Deploy contract: validate before reload¶
Every tenant deploy MUST caddy validate with its new drop-in staged, and roll back its
own <app>.caddy on failure, before sudo -n systemctl reload caddy. Caddy reload is
atomic — it rejects an invalid merged config and keeps the last-good config serving — so a
broken drop-in from one tenant cannot take the others down; it merely fails to apply.
Validate-before-reload turns that silent no-op into a caught deploy failure. A single shared
sudoers line (deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy, file
/etc/sudoers.d/99-caddy-reload) covers every tenant that deploys as the deploy@ user.
6. Terraform apply semantics — two very different changes¶
- (A) Firewall (
hcloud_firewall.main) — a real TF resource. Adding inbound 80 + 443 shows an in-place update (~) intofu plan. Verify it is NOT a server-/+replace, then apply. Immediate, reversible. - (B) cloud-init (Caddy install / base Caddyfile / sudoers / fail2ban) —
write_files/runcmdrun on first boot only.hcloud_server.prodsetslifecycle { ignore_changes = [user_data, ssh_keys] }(main.tf), so committing the cloud-init edit produces no plan diff at all — not a replace. The commit is therefore purely for future rebuilds; the live box gets Caddy via the imperative-once-as-root path (the tailscale-serve precedent). Never apply a plan that replaces the VPS (2026-05-29 prod-destruction guardrail).
7. Tenant lifecycle — a tenant can leave cleanly¶
A tenant is a pure drop-in: its <app>.caddy, its loopback port, its DNS, its cert. Removal
= pull the vhost + reload (frees the port); nothing shared changes. In particular orrery
carries zero shared-config entanglement and its domain is isolated, so a future migration
of orrery to its own VPS is rm /etc/caddy/sites/orrery.caddy + reload here, plus DNS
re-point. The podcast-family platform domain excludes orrery for the same reason.
Consequences¶
Positive
- Reusable N-tenant edge: each new public app is one drop-in vhost + one reserved port, no new infra. Shared HSTS/logging policy lives in one place.
- Vendor-neutral: standard ACME / Let's Encrypt, self-hosted, swappable; no account tie-in.
- Tenants are isolated and independently removable; orrery can migrate off with no blast radius. Rollback is clean (stop Caddy / pull vhost → public down, tailnet still up).
Negative
- The VPS public IP becomes visible (the trade-off vs. a tunnel). Mitigated by fail2ban, a small static-file attack surface, and keeping the box patched.
- One shared edge is a single point of failure for all public apps on the VPS. Acceptable at this scale; Caddy is battle-tested and reload is zero-downtime.
- New supply-chain surface in cloud-init: the Caddy apt repo + GPG key and the
caddypackage (a conscious dependency addition).
Neutral
- ADR-083's tailnet private ingress is unchanged — Tailscale stays the admin/deploy plane; Funnel is not used.
- fail2ban ownership, unassigned in both tracking issues, is claimed here as host infra.
Alternatives considered¶
- Tailscale Funnel — rejected: Funnel only presents a valid cert for the node's
*.ts.netname, so a custom domain gets a cert-name mismatch; it also exposes the tailnet node. Correct posture is to keep Tailscale private and stand a separate public ingress beside it. - Cloudflare Tunnel / cloudflared as primary ingress — rejected: couples the primary path to a vendor, against the operator's vanilla constraint. Retained only as a possible additive CDN front later (zero origin change, since the origin serves its own valid cert).
- nginx + certbot — rejected: same standard ACME outcome but with manual renewal plumbing (timer, cert mounts, reload hooks). Caddy gives automatic HTTPS with none of it, still 100% standard ACME.
- Wildcard cert (
*.domain) via DNS-01 — rejected for now: DNS-01 needs a DNS-provider API token (vendor coupling the operator dislikes). Per-host HTTP-01 issuance is vendor-neutral and well within Let's Encrypt rate limits at this tenant count. Revisit if the subdomain count grows large. - Per-app Caddy processes — rejected: defeats the shared-edge goal, multiplies port/cert management, and a single reload-validated engine already isolates tenant failures.