Skip to main content

ADR-015: Client portal subdomain SSL strategy

Renumbered from ADR-010 in #937. The MVP P0 NFR doc reserved 010 for AWS Secrets Management (T2 follow-up). No content changes.

Status: Accepted (2026-05-27) Driver: #784 β€” Invitation email link blocked:origin failure Affected: dev (Vercel), staging/prod (AWS β€” once #701 lands)

2026-06-01 update: Staging is now booting on ECS Fargate (#1303, building on #1101). The wildcard-ACM cert wiring described in §"Staging & Production" is the immediate next infra task; #701 has effectively been absorbed by the #1101 ECS migration track. Promotion flow dev→staging→main is now CI-enforced (#1559); prod envs/prod terraform bootstrapped (#1529). Wildcard ACM cert wiring + DNS still TBD.

Context​

Client portals live at {slug}.{HWORK_DOMAIN}:

EnvDomainHosting
dev{slug}.h852.workVercel
staging{slug}.h853.workAWS (target) β€” domain canonicalized per #1476/#1537
prod{slug}.h.workAWS (target β€” #701)

Onboarding sends https://{slug}.{HWORK_DOMAIN}/onboarding?token=…. For the link to load, every {slug} host needs (a) DNS resolution and (b) a valid TLS cert. With ~1 new client per onboarding, automation matters β€” no AM should manually attach a domain per client.

What broke​

#784: an AM created vk-corp on dev β†’ invite email built https://vk-corp.dev.h852.work/. Browser β†’ ERR_NAME_NOT_RESOLVED.

The frontend code (frontend/src/lib/subdomain.ts, frontend/src/middleware.ts) already correctly extracts the slug from the Host header β€” but no DNS or cert exists for the per-slug host. The code path was fine; the infra was missing.

Why "just add a wildcard" isn't a one-size-fits-all answer​

Each hosting provider issues TLS certs differently:

  • AWS (ACM): Issues wildcard certs (*.example.com) via DNS-01. ACM tells you "add this CNAME to your DNS." You add it once, anywhere (Route 53 OR Cloudflare OR any DNS provider), and ACM auto-renews the wildcard cert forever. One-time setup, zero per-tenant work.

  • Cloudflare Universal SSL: Auto-issues a wildcard cert for *.example.com (first-level only) on any zone using Cloudflare DNS β€” free. Does NOT cover *.dev.example.com (second-level wildcards require Cloudflare Advanced Certificate Manager, paid add-on).

  • Vercel: For wildcard certs, Vercel does DNS-01 itself β€” which requires the domain's nameservers to be on Vercel (ns1.vercel-dns.com, ns2.vercel-dns.com). Source. There is no way to use a Vercel wildcard with externally-hosted DNS. Without that, the only path is per-domain attachment (Vercel does HTTP-01 per host, one Let's Encrypt cert per attached domain).

Decision​

Two strategies, each correct for its environment.

Staging & Production (AWS, target state per #701) β€” wildcard ACM​

  • One ACM cert per env: *.h853.work, *.h.work.
  • Cloudflare DNS holds the ACM validation CNAME + the wildcard CNAME to the AWS endpoint (ALB or CloudFront distribution).
  • Zero per-tenant code. Every new {slug} Just Works.

Development (Vercel) β€” programmatic per-domain via Vercel API​

  • Cloudflare DNS keeps h852.work. One wildcard DNS record: CNAME *.h852.work β†’ cname.vercel-dns.com (DNS-only, gray cloud). Done once by ops.
  • On every createAmOrg, the API calls POST /v10/projects/{id}/domains with {slug}.h852.work so Vercel terminates TLS for that host and issues a per-host Let's Encrypt cert.
  • On deactivateOrg, mirror call: DELETE /v9/projects/{id}/domains/{host}.

Why programmatic-per-domain on dev (not migrate h852.work NS to Vercel)​

OptionProgrammatic per-domain (chosen)Move NS to Vercel
One-time effortAdd ~150 lines + Vercel API token + 1 CF DNS recordRegistrar NS swap (24-48h propagation), no rollback during propagation
Per-tenant effortOne API call, fire-and-forgetNone
Cloudflare features retainedDNS, email worker, proxy/WAFNone for h852.work
Symmetry with staging/prod (AWS)Asymmetric (dev has code, prod has none)Asymmetric anyway
Failure blast radiusOne client's portal doesn't resolve until manually attachedAll dev hosts dark for the propagation window

Dev/prod were going to diverge anyway because Vercel β‰  AWS. A few hundred lines of dev-only code is a smaller and more reversible cost than a nameserver migration. The decision can be revisited if dev volume grows enough that manual recovery from a failed attach becomes painful.

Implementation​

  • api/src/vercel/vercel-domain.service.ts β€” attachSlug / detachSlug methods. Gated to APP_ENV === 'development' at the top of each method; silent no-op on staging/prod. Missing config β†’ warn + no-op (never throws).
  • OrganizationsService.createAmOrg β€” calls attachVercelDomainForOrg(slug) fire-and-forget after the org row is committed.
  • OrganizationsService.deactivateOrg β€” mirror detachVercelDomainForOrg.
  • Required env (dev only): VERCEL_API_TOKEN, VERCEL_PROJECT_ID. Optional: VERCEL_TEAM_ID (humanwork's project lives in the team).

Verification​

  • api/test/vercel-domain.service.spec.ts pins the env gate, missing-config noop, idempotency (409 attach / 404 detach), and network-error swallow.
  • api/test/organizations-service-unit.spec.ts pins the wiring: createAmOrg fires attachSlug(slug) without blocking, deactivateOrg fires detachSlug, service is a no-op when DI omits it.

Idempotency contract​

  • Attach: Vercel returns 409 domain_already_in_use for an already-attached host on the same project β€” treated as success. (Different project ownership returns domain_already_owned; treated as a real error and logged.)
  • Detach: Vercel returns 404 for a not-attached host β€” treated as success.
  • Both methods are called via void/fire-and-forget at the org service layer (same pattern as agent runtime provisioning, #763). Org creation must NEVER fail because a Vercel API call hiccupped.

Migration off Cloudflare Email Routing for h852.work​

h852.work is on Cloudflare Email Routing today (MX route{1,2,3}.mx.cloudflare.net). This decision keeps DNS on Cloudflare, so email routing keeps working as-is. If we ever move h852.work off Cloudflare for any reason (NS migration, zone consolidation), email routing needs a parallel re-home or decommissioning β€” not affected by this ADR.

Future work​

  • #701: when AWS hosting lands for staging/prod, request *.h853.work and *.h.work ACM certs and wire the AWS endpoint behind them. No code change needed; VercelDomainService already no-ops on those envs.
  • Slug rebrand (rebrandOrgSlug): should detach the old slug and attach the new one. Out of scope for #784; tracked as a follow-up.
  • Cron reaper: periodically reconcile organizations.slug (live) vs Vercel's attached-domain list for the dev project, to catch any drift from missed fire-and-forget calls. Out of scope for #784.