T2: Ansible ZDS role + Caddy/TLS + smoke deploy
closedParent: bug 75966cc (cell architecture tracking).
Goal
Deploy ZDS on cell hosts via Ansible, behind Caddy with TLS, version-pinned.
Scope
- New
deployment/roles/zds/: install (binary build or pinned image), config template (ZDS_*: host/port,ZDS_DB,ZDS_BLOBSTORE_PATH, handle domains, secrets), systemd unit, data dirs, firewall. - Caddy vhost for the PDS hostname + ACME; firewall rules.
- Wire into
deployment/playbooks/site.ymlwithzdstags; extend smoke host coverage. Split Caddy/TLS into a second slice if the role grows.
Acceptance
just update <cell> --tags zdsconverges idempotent; ZDS healthy behind Caddy with valid cert; version pinned per env and recorded.
9 Comments
T2: Per-domain ZDS behind Caddy — Implementation Plan
Goal: Each hosted domain gets its own unpatched ZDS process on the cell, reachable at
<slug>.pdsN.<region>.sovrn.at+ vanity*.at.<domain>, behind Caddy, with sovrnd owning the full unit lifecycle. No ZDS patches, no restarts for domain onboarding, no customer state in Ansible.Architecture: Ansible ships the binary, a user-level
[email protected]template, Caddy with a sovrnd-rendered upstream map, and the cell’s single ZDS secret set. sovrnd reconciles all units on startup (daemon-reload, start units in the hosted-domain inventory, stop any not in inventory) and owns lifecycle thereafter — sovrnd + ZDS units are a package. One sqlite + blob dir per domain; migration unit == data directory (ADR-0009 D32).Tech Stack: ZDS (nix-pinned in
nix/pkgs/zds.nix, unpatched), Caddy 2.11.x (on-demand TLS +map+ loopback admin API), systemd user units (--user, lingering), Go (sovrnd lifecycle module), ansible-vault, wildcard DNS. Logs forward to VictoriaLogs (T7); journald is extremis-only fallback.Locked decisions (from design discussion)
server.zig,identity.zig) costs more over time than the supervisor code, which is ours and doesn’t rot on upstream refactors.<slug>.pdsN.<region>.sovrn.at. One wildcard record per cell (*.pdsN.<region>.sovrn.at→ cell) covers all future slugs — no per-domain DNS provisioning or propagation wait. Accepted cost: cross-cell moves rotate the PDS server DID via the standard PLC/serviceEndpoint flow. (Cell-independent origins rejected: per-domain DNS provisioning breaks self-serve onboarding.)<user>.at.<domain>via customer wildcard*.at.<domain> CNAME <slug>.pdsN.<region>.sovrn.at. One record per hosted domain, zero touch per user. ZDS resolves via/.well-known/atproto-did(lookup is authoritative; unknown names 404).ZDS_JWT_SECRET,ZDS_DPOP_SECRET,ZDS_ADMIN_TOKEN,ZDS_PLC_ROTATION_KEY), vault-first at bootstrap, living inhost_vars/<cell>/vault.yml. Blast radius is already the cell. Email via shared comail creds (ZDS_COMAIL_API_KEY/ZDS_COMAIL_DID,[email protected]).createAccount/reserveSigningKey/updateHandle//signupfrom public ingress; ZDS binds loopback-only; sovrnd calls over loopback.INVITE_REQUIRED=true+ sovrnd-minted single-use invite codes (admin token) as defense in depth with audit lineage. Operator account@sovrn.at(did:plc:54ba6vxrdbzi6nexczpk5xzb) is externally hosted (selfhosted.social) —ZDS_OPERATOR_HANDLE=sovrn.atis display-only.127.0.0.1:<port>) and triggers reload synchronously in the activation flow.sovrnuser gets a real home (/var/lib/sovrn), lingering enabled and asserted; units in~/.config/systemd/user/. Accepted: no UID isolation between domains (cell = blast radius), soft boot ordering vs system Caddy (health gates cover), lingering is load-bearing (asserted in prechecks + monitored in T7)./var/lib/zds/dbs/<reversed>.db(hosted domainmydomain.com→com.mydomain.db), all in one directory so Litestream watches a single path and each file is self-identifying per domain. Mapping is deterministic from the domain (lowercase ASCII/punycode, strip trailing dot, reverse labels,+ ".db"); the exact filename is also stored in thesovrn.dbregistry next to slug+port. Blob dirs stay per-slug (/var/lib/zds/blobs/<slug>/).Secrets classification
group_vars/all/vault-shared.yml+ existing OIDC/OAuth keys):ZDS_COMAIL_API_KEY,ZDS_COMAIL_DID; non-secretZDS_EMAIL_FROM, crawlers, proxy defaults.host_vars/<cell>/vault.yml, provisioned by recipe before bootstrap, committed ciphertext): the four ZDS secrets above. Never generated at runtime, never fetched back.sovrn.db(Litestream-replicated — restores with the cell, no fetch-back anywhere).Task 0: Spike — measure and verify assumptions
just smoke). 20 instances must fit a cell with headroom for Stalwart+sovrnd.map+ admin/loadgraceful reload — rewrite map, reload, confirm in-flight drain + immediate new-host routing.atprotoDidper instance;describeServeradvertises only its suffix; confirm loopback-only + Caddy blocks make creation sovrnd-exclusive with zero ZDS changes.Run:
ps -o rss/ dev Caddy +curl -fsSprobes. Expected: all green; RSS budget documented.Task 1: Cell-level ZDS secrets, vault-first
Files: - Create:
deployment/scripts/provision-zds-secrets- Modify:Justfile(provision-zds-secrets CELL,_precheckrequires host vault for cells) - Modify:deployment/inventory/host_vars/<cell>/vault.yml(ciphertext only) - Create:deployment/roles/zds/tasks/secret.yml(assert + materialize 0600 files,no_log)--dry-runrenders REDACTED, asserts structural validity, changes nothing.--force/--rotate=<key>for explicit rotation.jj commitrecipe + role + docs.Task 2:
[email protected]user template + cell provisioningFiles: - Create:
deployment/roles/zds/templates/[email protected](EnvironmentFile=%h/.config/zds/%i.env,Restart=always, hardening) - Create:deployment/roles/zds/tasks/main.yml(binary fromdeployment/.local/zds-prodwith existence assert + trixie glibc gate, template unit, dirs, lingering, Caddy skeleton) - Modify:deployment/roles/common/tasks/main.yml(sovrn home →/var/lib/sovrn) - Modify:deployment/playbooks/site.yml(zdstags),docs/deployment.md(tag list)%h/.config/systemd/user/,%h/.config/zds/,/var/lib/zds/dbs/(one directory holding every<reversed>.db, sovrn-owned),/var/lib/zds/blobs/<slug>/per domain.loginctl enable-linger sovrn; assertLinger=yesevery converge (fence: fail loudly, never silently run without it).just update <cell> --tags zdsgreen; rerun no-change; missingjust build-zdsartifact fails fast.sudo -u sovrn XDG_RUNTIME_DIR=/run/user/$(id -u sovrn) systemctl --user status zds@<slug>+just pds-statuswrapper (journald = extremis only).Task 3: sovrnd lifecycle module (reconcile-on-startup supervisor)
Files (sovrnd side, exact paths at implementation): - Create: lifecycle module —
sovrn.dbregistry (slug, port, origin, db filename, status), env renderer (ZDS_DB=/var/lib/zds/dbs/<reversed>.dbderived deterministically from the domain and stored in the registry),systemctl --userdriver (daemon-reload, start/stop/enable/disable), rolling-restart orchestration, per-instance health for T6 - Create: Caddy map renderer + admin-API reload trigger (synchronous in activation) - Tests: reverse-notation mapping (case, trailing dot, punycode/IDNA, multi-label e.g.example.co.uk→uk.co.example.db, Città di… — sample table in test), allocator (uniqueness, DNS-safe charset, never-reuse-after-retire, port range), renderer golden files, reload-failure rollbackdescribeServerbefore marking active.mail,admin, …;postmasterOK); non-conforming email names get documented alias rule. Reconciler pre-validates everything (ZDS keeps only syntax backstops).Task 4: Caddy — catch-all, on-demand TLS, map routing, creation blocks
Files: - Modify:
deployment/roles/caddy/templates/Caddyfile.j2- Modify:deployment/roles/caddy/tasks/main.yml(admin API loopback-only, map path)on_demand_tls { ask … }— ask allow-listed by active.at.<domain>suffixes (per-suffix, not per-user); upstream from map file.POST /xrpc/com.atproto.server.createAccount,POST /xrpc/com.atproto.server.reserveSigningKey,POST /xrpc/com.atproto.server.updateHandle,GET /signup. All other authed user ops (sessions, OAuth, records, blobs, sync) stay public.Task 5: Smoke, migration, recovery drills
/.well-known/atproto-did→ OAuth discovery → blob round-trip → CAR (ZDS_URL=https://<slug>.pdsN… bash scripts/smoke-zds.shshape).<reversed>.db, self-identifying) + blobs to second host, start unit, flip vanity CNAME, PLC serviceEndpoint/rotation update; assert continuity.sovrn.dbrestores registry (incl. db filenames), vault supplies cell secrets, reconciler regenerates env+map (incl. lingering + unit state checks), per-domain DBs restore into the single watched directory (Litestream single-path watch — T4 detail). No fetch-back at any step.Self-review
Task 0 spike results (2026-09-14): GO across the board
All read-only; repo untouched, scratch in /tmp, no processes left behind. Binary reused from
deployment/.local/zds-prod(no rebuild).smoke-zds.shrun; second instance consistent. 20 x 23 MB ~= 460 MB — fits a cell with large margin. Caveat: single-account measurement; per-account growth unmeasured (follow-up spike recommended before freezing cell capacity).map {host} {upstream}+caddy reloadroutes new hosts immediately, in-flight slow request drained across reload (PID unchanged), unmapped hosts 502 fail-closed. Gotcha recorded:caddy reload --addresstakeshost:port, not a URL. Note:Caddyfile.j2has no map/ZDS vhost yet — that lands in Task 4.describeServeradvertises only its suffix; vanity/.well-known/atproto-didresolves per-instance, 404 cross-instance; wrong-suffix createAccount -> 400UnsupportedDomain; loopback-only bind confirmed (ss -ltn). Source cross-check: env-at-startup, zero reload hooks — one process per domain set is required, matching the plan.Proceeding to Task 1.
Task 1 complete (spec ✅ file-level, quality ✅ on re-review)
deployment/scripts/provision-zds-secrets(vault-first per-cell recipe: 4 secrets, secp256k1 range-checked rotation key, fill-missing-only, REDACTED dry-run),Justfilerecipe,deployment/roles/zds/tasks/secret.yml(asserts incl. hex/dpop/range guards mirror the generator, 0600 materialization).secret.ymlgets wired intosite.ymlin Task 2 (not Task 1, which was forbidden from changing bootstrap semantics).Task 2 complete (spec ✅, quality ✅ on re-review)
[email protected]user template (default deps,%islug,%hpaths,Restart=always, moderate hardening with rationale),roles/zds/tasks/main.yml(binary assert → glibc gate → copy → dirs incl. single/var/lib/zds/dbs/→ template → linger+assert → user-manager start+reload),site.ymlwiring (zds-secretafterstalwart-secret,zdsafterstalwart-install— also resolves the Task-1 wiring ruling), sovrn home →/var/lib/sovrn+sovrn_homevar, docs tag hunk.WorkingDirectorydoes not strip quotes (quoting attempt correctly reverted);sovrn_zds_binvar added and consumed in copy dest + ExecStart.just update <cell> --tags zdsmust confirm linger + manager start on a fresh box.Task 3 complete (spec ✅, quality ✅ on re-review after fixes)
internal/pdslifecycle(registry with tombstones, reverse-notation mapping with IDNA, env renderer with goldens, Runner interface + systemd/fake, supervisor with reclaim semantics, Caddy map renderer + contract doc, handle rules mirroring ZDS) + opt-in wiring (pds.supervision, default off, never hard-fails startup).retiringrows bricked retries) — fixed via reclaim-same-slug+port + fail→retry tests; plus Supervisor/FakeRunner mutexes (-raceclean), failure-path test coverage, SetStatus-by-id, errors.Is. Re-review APPROVED (checked reclaim/port-uniqueness walk, lock hierarchy deadlock-free).Task 4 live-probe transcript (G2 evidence, real Caddy 2.11.4 via nix + real sovrnd + stub backends)
Rendered production-form Caddyfile from the new
Caddyfile.j2with sample vars →caddy validate: Valid configuration. Test variant differed only in high ports +cert_issuer internal+auto_https disable_redirects(routing/L7/map/ask logic identical).Ask matrix, real sovrnd (
GET /internal/caddy-ask?domain=):Routing over
https://…:18443(internal CA,curl -k --resolve):L7 via PDS host (all 403):
POST createAccount,POST reserveSigningKey,POST updateHandle,GET /signup,GET /signup/(G5). Method scoping intact:POST /signupandGETon createAccount-path proxy through (not blocked).App/mail preserved (real sovrnd behind app vhost — 401⁄303 = proxied, never 403): three POST creation paths → 401;
GET /signup+GET /signup/→ 303; app/internal/caddy-ask→ 404; did.json OK; mail/404 + challenge path reaches proxy.Reload: map append + backend C + 4s in-flight
/slow→caddy reload --address 127.0.0.1:2019clean; in-flight drained (pds-A:/slow); new origin + new unknown-user vanity route immediately. Adapted servers['srv0','srv1','srv2']; SNIsovrn.aton PDS port → handshake fail (never reaches PDS).SOVRN_INTEGRATION=0 go test ./...green; snippet/goldens consistent. All probe processes killed, ports clear, scratch deleted.Still real-cell-only: LE ACME issuance, Http01/Stalwart coexistence, wildcard DNS, first
just update <cell> --tags caddyconverge.Task 4 complete (spec + quality ✅ after fix round)
Caddyfile.j2: global on-demand TLS (ask from sovrn vars), preserved app/mail blocks + deliberate/internal/*404 guard (verified: no legitimate app route under/internal/), least-specific:443PDS fallback (most-specific-wins proven), origins map + regex vanity collapse (no per-user entries), fail-closed 502, 4 L7 blocks PDS-scoped, stagingcaddy_acme_cavar, LE-storage backup note./etc/caddy/maps.d(root:sovrn 0775), Caddyfile dir root:root 0755 — sovrn moves map data + loopback reload only. Ask trailing-dot deny,/signup*coverage, perm consistency all fixed.GET /internal/caddy-ask?domain=(decision fn + table tests in pdslifecycle, thin router wiring, deny-by-default incl. supervision-off).2ef08fa) — exact origins, unknown-user vanity, unknown-suffix handshake refusal, reload immediacy + drain, ask matrix, L7 ×4 on PDS vs app-unaffected, SNI isolation.just update <cell> --tags caddyconverge.Task 5 complete (spec + quality ✅ after fix round)
scripts/smoke-zds.shgainsZDS_HANDLE_SUFFIX+ opt-inZDS_CHECK_VANITY(default path byte-identical, proven live); vanity documented in both modes (loopback Host-spoof + Caddy-path https).docs/runbooks/pds-domain-migration.md(steps + verification + rollback + TLD onboarding constraint) anddocs/runbooks/pds-cell-recovery.md(regen procedure, verified-vs-cell-only boundary, §6 T7 label contract). Drill findings written in-repo (TLD note, on_demand ask-form comment)./tmp/t2-spikeremoved,__pycache__ignored + untracked, drill ports clear,go test ./...green.T2 implementation complete (all Tasks 0–5 spec+quality approved)
Final cross-task review found one blocker and it is fixed + verified:
LoadCellSecrets(4 host files, trailing-newline trim, skip-with-log vs hard errors, values never in errors/logs) + deterministic derivation (origin→host→did:web, handles.<host>,.at.<domain>) + Reconcile order env→units→maps→reload with per-unit skip. Recovery invariant documented: registry + cell secrets + binary ⇒ all runtime state recomputed. Verified incl.-race, redaction tests, golden byte-identity.just pds-statusrecipe added (3 runbook refs resolve);provision-cell.mdTODO/order fixed to matchsite.yml.SOVRN_INTEGRATION=0 go test ./...green (22 pkgs), vet/gofmt clean, reproducibly.Tracked follow-up (real, out of T2 scope): comail/crawler provisioning. Sovrnd reads
SOVRN_PDS_COMAIL_API_KEY/_DID/_CRAWLERSvia new bindings, but nothing sets them —sovrn.toml.j2has no[pds]section and nosovrn_comail_*vars exist anywhere. Until provisioned, empty values fail per-record env loudly by design (unit skipped, tested). Needs: storage decision (fleet vs per-cell vault), recipe support, group-vars +[pds]template block, first-cell converge proof. Suggest T2-followup bug.Residuals (all previously recorded, unchanged): live LE/DNS/fresh-box converge/R2 round-trip/vault materialization; RSS-under-load spike before freezing cell capacity.
Working copy: T2 work complete but UNCOMMITTED, interleaved with pre-existing unrelated mods — commit topology left to owner (see chat).