Custom domains (bring-your-own domain via Cloudflare for SaaS)
Clinics serve the platform on their own domain (e.g. patients.theirclinic.com) instead of {slug}.portal.restartix.pro. Gated by the custom_domain entitlement (pro / dedicated tier). No per-clinic infrastructure — registration is fully runtime via Cloudflare for SaaS; nothing is hardcoded per domain.
Status (2026-06-05)
- Portal custom domains — SHIPPED + proven end-to-end on staging.
portal.restartix.rowas registered → Cloudflare issued the TLS cert →verified→ portal served at the custom domain with a valid padlock, Clerk login, and profile creation all working. Commits: control plane4ff8be9, data plane (dynamic CORS + ALB catch-all)8a8c32e, staging CNAME-target wiring4964709, hardcoded-platformaremoval8725a71. - Clinic custom domains — DEFERRED behind a guard. Routing is designed (see "Clinic routing" below) but intentionally not built — staff use the back-office on our subdomain (
{slug}.clinic.restartix.pro), which is enough for now.AddDomainrejectsdomain_type=clinic(clinic_custom_domains_unavailable, commitbb9450a) so a clinic-type domain can't be registered + silently mis-routed to the portal while the dispatcher is absent. The on-switch is: delete that guard + offer the option in the Add-domain dialog + ship the dispatcher. It's a clean ~1-day additive build (no migration, no rework) — the schema already carriesdomain_type.
Architecture — the proven portal path
- Register. Admin adds a domain (Console/Clinic → Domains, route gated on
RequireTierEntitlement("custom_domain")) →organization.AddDomain→cloudflaresaas.Register(Cloudflare Custom Hostnames API) → storescloudflare_hostname_id+ssl_statusonorganization_domains(cols added to migration000002) → returns the CNAME instruction:CNAME {domain} → CLOUDFLARE_SAAS_CNAME_TARGET(=customers.restartix.pro). - Clinic CNAMEs their domain →
customers.restartix.pro(a proxied record in therestartix.prozone). If the clinic's domain is itself on Cloudflare, that CNAME must be DNS-only (else Orange-to-Orange conflict). For domains on any other DNS provider it's just a plain CNAME — and we still issue their TLS (that's the whole point of Cloudflare for SaaS). - Cloudflare for SaaS validates ownership (HTTP-01) + issues a Let's Encrypt cert at the edge, then forwards to the zone fallback origin (
portal-{env}.restartix.pro). - Edge → origin → app. CF → fallback origin → ALB →
aws_lb_listener_rule.custom_domain_catch_all(priority 50000, path/*) → portal target group. Platform hosts keep matching their explicit higher-priorityhost_headerrules first. - Resolve + serve. Portal
proxy.ts→resolveOrganizationByDomain(host)→ finds the verified org → serves it. - CORS.
middleware.CORSconsultsorganization.NewCORSOriginAllower(verifiedorganization_domainslookup, Redis negative-cached 60s) only on a static-allowlist miss → custom domains pass cross-origin with zero env-list entries. - Status refresh.
VerifyDomain→cloudflaresaas.GetStatus→ maps CF status → flipspending → verified(the Verify button; a sweep cron is a deferred follow-up).
Config + one-time setup
- api task def env:
CLOUDFLARE_SAAS_API_TOKEN+CLOUDFLARE_ZONE_ID(from SMrestartix/{env}/cloudflare) +CLOUDFLARE_SAAS_CNAME_TARGET(=customers.restartix.pro). - Out-of-band, once per zone (NOT Terraform): enable Cloudflare for SaaS on the zone; create the fallback origin (proxied →
portal-{env}.restartix.pro); create the proxiedcustomers.restartix.proCNAME target. The API token needsZone:DNS:Edit+Zone:SSL and Certificates:Edit. - No Pro-plan upgrade — Cloudflare for SaaS custom hostnames work on all plans (an included quota + pay-as-you-go beyond). The fallback origin is the real required step, and it's a setting, not a plan gate.
- Shared-zone caveat: the fallback origin is one per zone, and
restartix.prois shared staging+prod. Set it toportal-stagingfor testing; the clinic-routing build (below) introduces per-hostname custom origins, which is also what lets staging + prod custom domains coexist on the one zone.
Clinic routing — DESIGNED, deferred behind a guard
Status: deferred.
AddDomainrejectsdomain_type=clinic(commitbb9450a) until the dispatcher below ships. Everything in this section is the build that flips it on; none of it is wired today.
Problem. The ALB routes by Host header. Custom domains arrive as arbitrary Hosts, so the ALB cannot tell a clinic-type domain (staff.clinic.com) from a portal-type one — and one catch-all rule can only target one app (portal today). Per-hostname custom origins do not fix this: Cloudflare for SaaS still sends the custom hostname as the Host, so the ALB still can't match it to the clinic rule (see the known "Host header override" limitation).
Recommended design — a Cloudflare Worker dispatcher bound to the custom hostnames:
- Reads the incoming
Host(the custom domain). - Resolves
domain_type(clinic|portal) viaGET /v1/public/organizations/resolve?domain=X(extend the response to includedomain_type), cached in Workers KV. - Forwards to the right backend with the matching Host (
clinic.restartix.pro/portal.restartix.pro) so the ALB's existing host_header rule routes it, and passes the original custom domain inX-Forwarded-Hostfor the app's resolve.
Required changes:
proxy.ts(clinic and portal): resolve-by-domain fromX-Forwarded-Host(fall back toHost).resolveendpoint: adddomain_typeto the public response (the Worker needs it).cloudflaresaas: optionally set a per-hostname custom origin ={clinic|portal}-{env}.restartix.properdomain_type(also resolves the shared-zone fallback-origin split).- The Worker (new): the dispatch layer. No second ALB catch-all is possible.
Alternatives considered + rejected: per-domain CF Origin Rules with a Host override (doesn't scale — per-clinic config); portal-app reverse-proxy of clinic traffic (architectural coupling); two separate SaaS zones (heavy).
Open / deferred
- platforma.restartix.ro: no longer hardcoded (
8725a71). At prod launch, register it as the demo org's portal custom domain via the platform's Add-domain flow (the proven path) — add this to the production-apply runbook. - Status sweep cron: today
VerifyDomainis UI-triggered; a scheduled sweep ofpendinghostnames would complete provisioning hands-off. - Telemetry CORS: telemetry's CORS middleware is static-only (no core-DB access). If a custom-domained portal needs browser→telemetry cross-origin (media QoS ingest), telemetry needs a cross-service verified-domain check.
- Production wiring: the prod
apitask def already pullsCLOUDFLARE_SAAS_API_TOKEN+CLOUDFLARE_ZONE_IDfrom Secrets Manager;CLOUDFLARE_SAAS_CNAME_TARGETwas the one gap, now wired ininfra/envs/production/compute.tf. The remaining steps are out-of-band at prod-provisioning time (Cloudflare-for-SaaS fallback origin +customers.restartix.proCNAME on therestartix.prozone) — documented in production-apply-runbook §6.2.
Production-only feature (the shared-zone non-issue)
Custom domains are a production feature — the staging setup was scaffolding to prove the path and isn't needed long-term. Because only production registers custom domains, the single Cloudflare-for-SaaS fallback origin per zone just points at the prod portal; there is no staging-vs-prod fallback-origin conflict and therefore no per-hostname custom origin to build. (If we ever wanted custom domains live in both envs simultaneously on the shared restartix.pro zone, that's when per-hostname custom_origin_server would be required — not before.)