Dev HTTPS & DNS Provisioning — Tenant Subdomains Across the PSL Boundary
Audiences: developer, internal
Each tenant lives on its own subdomain (
<slug>.dudoxx.comin prod,<slug>.localhostin dev). Making that subdomain both resolve and prove its TLS identity differs sharply between environments. In dev, Next.js serves experimental HTTPS from an mkcert leaf whose SAN list enumerates every tenant slug, regenerated per-tenant during provisioning;*.localhostresolves to 127.0.0.1 automatically so no DNS step is needed. In prod, a real<slug>.dudoxx.comCNAME is created via ddx-dns-api and TLS terminates on the home-edge Apache wildcard. This was built to kill theNET::ERR_CERT_COMMON_NAME_INVALIDerror that broke local tenant logins.
Business Purpose
The self-serve onboarding flow ends by redirecting a freshly-provisioned clinic to its own subdomain to log in. For that redirect to land on a working page, two things must hold: the browser must resolve the subdomain to an IP, and the TLS handshake must present a certificate the browser trusts for that exact name. In production both are solved by infrastructure (a wildcard TLS terminator and a DNS API). On a developer laptop neither is free — and a naive wildcard dev cert fails silently.
The subtle defect: *.localhost is on the Public Suffix List (PSL). Chrome treats each PSL entry as a registry boundary, so it refuses to honour a wildcard certificate across that boundary — a cert valid for *.localhost does not cover acme.localhost. The result was NET::ERR_CERT_COMMON_NAME_INVALID the moment a developer was redirected to a tenant subdomain, blocking every local end-to-end onboarding run. The fix: enumerate every tenant slug as its own explicit DNS SAN in the dev cert, and regenerate that cert (with the new slug) as a provisioning step.
Business outcome: a developer can run the entire paid-onboarding flow locally and land on a green-padlock acme.localhost:6200/auth/login, instead of a browser interstitial; and in production the same flow creates a real CNAME and serves real TLS — with the dev machinery completely no-op'd.
Audiences
- Investor: per-tenant subdomains with valid TLS are table stakes for a multi-tenant SaaS; the platform automates both provisioning paths (dev and prod) inside the same state machine.
- Clinical buyer: not customer-facing — but the clinic's own branded subdomain works on first login because cert and DNS are provisioned automatically.
- Developer/partner: dev TLS comes from
ddx-web/dev-certs/localhost-{cert,key}.pem, loaded by Next.js via--experimental-https-*flags at boot. Adding a tenant means re-runningscripts/regen-dev-certs.sh(idempotent, hashes the SAN set) and restarting ddx-web. Env knobs:DDX_DEV_CERT_AUTO_RESTART,DDX_MONOREPO_ROOT,DDX_EXTRA_TENANT_SLUGS. - Internal (ops/support): prod DNS is a real CNAME via ddx-dns-api (
DDX_DNS_API_URL+DDX_DNS_API_TOKEN); dev no-ops both DNS and (in prod) cert regen. The cert is loaded at Next.js boot, so a new SAN requires a ddx-web restart to take effect.
Architecture
The provisioning state machine runs two adjacent steps in the CREATING_DNS phase — create-dns-cname (name resolution) and regen-dev-cert (TLS identity) — both of which branch hard on NODE_ENV:
regen-dev-certs.sh reads the live tenant list from Postgres (SELECT slug FROM organizations), unions it with DDX_EXTRA_TENANT_SLUGS (the provisioning step passes the new tenant's slug here), builds a deterministic SAN set (localhost, *.localhost, every <slug>.localhost, 127.0.0.1, ::1), hashes it to dev-certs/.slug-hash, and only re-issues with mkcert when the hash changed or the cert is within 30 days of expiry. The provisioning step then runs ./ddx-manage.sh restart web (unless DDX_DEV_CERT_AUTO_RESTART=false) because Next.js loads the cert into its in-process https.Server only at boot.
Tech Stack & Choices
- mkcert leaf with explicit per-slug SANs — not a wildcard. The PSL boundary on
*.localhostmakes a wildcard useless for tenant subdomains in Chrome; each slug must be its own DNS SAN.*.localhostis retained in the SAN list only for non-tenant subdomains. - Next.js
--experimental-https— Next serves TLS directly in dev (no local reverse proxy), reading the cert/key paths from CLI flags. Thedevscript inddx-web/package.jsonconditionally adds the HTTPS flags only when both PEM files exist, so a developer without certs falls back to HTTP cleanly. - Cert regen as a provisioning step, not a hook —
RegenDevCertStepis part of the durable state machine so a newly-reserved slug is covered before the user is redirected. It is non-fatal: a regen or restart failure logs a warning and continues (the user sees at worst Chrome's "Proceed" interstitial, an annoyance not a blocker). - Idempotent regen via SAN hash — re-running the script when nothing changed is a no-op (
.slug-hash+ a 30-dayopenssl x509 -checkendvalidity check), so the step is cheap to call on every provision. - DNS dev/prod split via
mode—DnsProvisioningServicederivesmodefromNODE_ENV. Dev returns{ ok: true, note: 'dev no-op' }immediately (relying on*.localhostauto-resolution); prod POSTs a real CNAME to ddx-dns-api with Bearer auth, retry/backoff, and aDELETErollback path.DDX_DNS_API_TOKENis empty in dev (and throws if missing in prod). resolveMonorepoRoot()— the step locatesddx-manage.shby honouringDDX_MONOREPO_ROOTor walking up to 6 parents fromprocess.cwd(), so it works regardless of where ddx-saas was launched.
Data Flow
- A new tenant slug is reserved earlier in the provisioning pipeline.
create-dns-cname.stepcallsDnsProvisioningService.createSubdomain(slug). Dev: structured no-op log, returns ok. Prod:POST ${DDX_DNS_API_URL}/recordswith{ name: '<slug>.dudoxx.com', type: 'CNAME', value: 'edge.dudoxx.com.', ttl: 300 }+Authorization: Bearer <DDX_DNS_API_TOKEN>, with[200,600,1800]msretry on 502/503/504; recordsdnsTargetartifact.regen-dev-cert.stepruns next (sameCREATING_DNSphase). Prod: immediate no-op. Dev:execFile(scripts/regen-dev-certs.sh, [], { env: { ...process.env, DDX_EXTRA_TENANT_SLUGS: slug }, timeout: 30s }).- The script unions the new slug with the Postgres slug list, rebuilds the SAN set, and re-issues
localhost-cert.pem+localhost-key.pemvia mkcert only if the SAN hash changed. - Unless
DDX_DEV_CERT_AUTO_RESTART=false, the step runs./ddx-manage.sh restart web(detached, 60s timeout) so Next.js reloads the new cert before the user can click the "Ready" redirect. - The onboarding status page redirects the browser to
https://<slug>.localhost:6200/auth/login(dev) orhttps://<slug>.dudoxx.com/auth/login(prod) — green padlock either way.
Implicated Code
ddx-web/package.json— thedevscript conditionally appends--experimental-https --experimental-https-key dev-certs/localhost-key.pem --experimental-https-cert dev-certs/localhost-cert.pemwhen both PEMs exist;dev:httpsforces it.scripts/regen-dev-certs.sh— pre-flightmkcert/psqlchecks; Postgres slug query (SELECT slug FROM organizations);DDX_EXTRA_TENANT_SLUGSunion; deterministic SAN list (localhost,*.localhost,<slug>.localhost,127.0.0.1,::1); SAN-set hash atdev-certs/.slug-hash; 30-day validity skip;mkcert -cert-file localhost-cert.pem -key-file localhost-key.pem <sans...>.ddx-saas/src/provisioning/steps/regen-dev-cert.step.ts—RegenDevCertStep(name = 'regen-dev-cert'); prod no-op;resolveMonorepoRoot();execFileAsyncof the regen script withDDX_EXTRA_TENANT_SLUGS=<slug>; non-fatal warn-and-continue;DDX_DEV_CERT_AUTO_RESTARTgate; detachedddx-manage.sh restart web; no-op rollback.ddx-saas/src/provisioning/steps/create-dns-cname.step.ts—CreateDnsCnameStep(CREATING_DNSphase); delegates toDnsProvisioningService.createSubdomain(); recordsdnsTargetartifact; rollback viadeleteSubdomain().ddx-saas/src/platform/dns/dns-provisioning.service.ts—modefromNODE_ENV; dev no-op vs prod CNAMEPOST /records/DELETE /records/:fqdn;resolveToken()(throws ifDDX_DNS_API_TOKENabsent in prod);fetchWithRetry()with[200,600,1800]msbackoff;healthCheck().
Operational Notes
*.localhostis on the PSL — Chrome ignores wildcard certs across the PSL boundary, so a tenant subdomain needs an explicit SAN. This is the root cause of the originalNET::ERR_CERT_COMMON_NAME_INVALID; the wildcard SAN alone is not enough.- Cert is loaded at Next.js boot — a freshly-issued cert with a new SAN does nothing until ddx-web restarts. The provisioning step does this automatically unless
DDX_DEV_CERT_AUTO_RESTART=false, in which case the operator must./ddx-manage.sh restart web. - Regen is idempotent and cheap —
dev-certs/.slug-hash+ a 30-day expiry check short-circuit a no-change run, so the step is safe to invoke on every provision. - mkcert must be installed and its CA trusted (
brew install mkcert nss); without it the script errors and the (non-fatal) step logs a warning, leaving the developer to click through Chrome's interstitial. - Prod DNS requires
DDX_DNS_API_TOKEN—resolveToken()throwsDDX_DNS_API_TOKEN required in productionif absent. In dev the token is empty and the DNS step is a no-op by design. - The dev↔prod matrix in one line: dev = mkcert per-slug SANs +
*.localhostauto-resolve + DNS no-op; prod = wildcard TLS on the home-edge Apache + real<slug>.dudoxx.comCNAMEs via ddx-dns-api.
Related Topics
- Tenant Provisioner — the provisioning pipeline these two steps belong to (org + FHIR partition + MinIO buckets alongside DNS + cert).
- SaaS Self-Serve Onboarding — the flow whose final subdomain-login redirect depends on cert + DNS being provisioned first.
- MinIO — Object Storage Infrastructure — the per-tenant
{slug}-{type}buckets provisioned for the same slug whose subdomain is set up here.