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}:
| Env | Domain | Hosting |
|---|---|---|
| dev | {slug}.h852.work | Vercel |
| staging | {slug}.h853.work | AWS (target) β domain canonicalized per #1476/#1537 |
| prod | {slug}.h.work | AWS (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 callsPOST /v10/projects/{id}/domainswith{slug}.h852.workso 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)β
| Option | Programmatic per-domain (chosen) | Move NS to Vercel |
|---|---|---|
| One-time effort | Add ~150 lines + Vercel API token + 1 CF DNS record | Registrar NS swap (24-48h propagation), no rollback during propagation |
| Per-tenant effort | One API call, fire-and-forget | None |
| Cloudflare features retained | DNS, email worker, proxy/WAF | None for h852.work |
| Symmetry with staging/prod (AWS) | Asymmetric (dev has code, prod has none) | Asymmetric anyway |
| Failure blast radius | One client's portal doesn't resolve until manually attached | All 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/detachSlugmethods. Gated toAPP_ENV === 'development'at the top of each method; silent no-op on staging/prod. Missing config β warn + no-op (never throws).OrganizationsService.createAmOrgβ callsattachVercelDomainForOrg(slug)fire-and-forget after the org row is committed.OrganizationsService.deactivateOrgβ mirrordetachVercelDomainForOrg.- 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.tspins the env gate, missing-config noop, idempotency (409 attach / 404 detach), and network-error swallow.api/test/organizations-service-unit.spec.tspins the wiring:createAmOrgfiresattachSlug(slug)without blocking,deactivateOrgfiresdetachSlug, service is a no-op when DI omits it.
Idempotency contractβ
- Attach: Vercel returns 409
domain_already_in_usefor an already-attached host on the same project β treated as success. (Different project ownership returnsdomain_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.workand*.h.workACM certs and wire the AWS endpoint behind them. No code change needed;VercelDomainServicealready 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.