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. - Patient sign-in on portal custom domains — Clerk satellite domains, built 2026-09-23, not yet deployed. Replaces the first-party patient session the legacy sign-in hand-off minted on custom domains (removed the same day). See Patient sign-in.
- 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 with Clerk as a satellite when the domain is sign-in ready, and otherwise 307s to the clinic's{slug}.portal.restartix.pro(see Patient sign-in). - 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). The same call re-checks patient sign-in and stamps or clearsauth_verified_at.
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.
Patient sign-in — Clerk satellite domains
Cloudflare verifying a domain makes it routable; it does not let a patient sign in there. Clerk's production keys are bound to restartix.pro, and a clinic's own domain is a different registrable domain, so each one needs its own registration with Clerk — a satellite domain of the instance — plus DNS the clinic publishes for it. Two gates, recorded separately on organization_domains: status = 'verified' (Cloudflare) and auth_verified_at (Clerk). A portal domain is usable only when both are set.
Per-domain setup, once the domain is verified:
- Add the domain in the Clerk dashboard → Domains → Satellites (the production instance). Satellites are paid per domain, per Clerk pricing.
- The clinic publishes
CNAME clerk.<domain> → frontend-api.clerk.services— DNS-only if their zone is on Cloudflare, for the same Orange-to-Orange reason as step 2 above. - Press Verify on the domain (Console or Clinic → Domains).
platforma.restartix.ro is registered and verified as a satellite.
What Verify checks. VerifyDomain asks the auth provider on every run, through auth.DomainAuthChecker (internal/core/auth/domains.go; the Clerk implementation is auth/clerk/domains.go). Ready means both: the Clerk Backend API lists the domain as a satellite of this instance, andGET {frontend_api_url}/v1/environment answers 200 over valid TLS — which needs the CNAME published and Clerk's certificate issued. Neither is enough alone: Clerk accepts the registration before the DNS exists. Ready stamps auth_verified_at (an existing stamp keeps its timestamp); not ready clears it, and so does a domain Cloudflare no longer routes. When the provider cannot answer — its API down, the probe timing out — the previous state stands, so a blip never switches a working domain off. Never set by hand. Every verify also drops the org's resolve-by-slug cache entry, whose payload lists the domain.
What the Portal does per host (apps/portal/proxy.ts, lib/auth-domains.ts):
| Host | Clerk |
|---|---|
{slug}.portal.restartix.pro | Primary. Every sign-in and sign-up happens here. allowedRedirectOrigins is Clerk's defaults (restartix.pro and its subdomains) plus this clinic's sign-in-ready domains — no other custom domain, which keeps ?redirect_url= from being an open redirect. |
A custom domain in the resolve payload's auth_verified_domains | Satellite — isSatellite, domain = the host, signInUrl = the clinic's {slug}.portal.restartix.pro/sign-in. /sign-in and /sign-up on the satellite redirect to the primary, and the patient comes back signed in. |
| A custom domain not on that list | None. 307 to the clinic's platform subdomain, same path. Clerk loaded there would point at a clerk.<domain> that does not answer and break every page. |
Because sign-in always happens on the primary, the address bar briefly shows restartix.pro — Clerk's rule for satellites, not a choice. The satellite's redirect to sign-in goes through Clerk's redirectToSignIn, never a hand-built URL: Clerk marks the return URL __clerk_synced=false (its "needs sync" marker), and that marker is what makes the satellite pick up the session the primary just created.
auth_verified_domains lists the org's portal domains that are both verified and stamped, on the public resolve payload (always present, [] when none). That payload is why organization_domains.domain is classified public: it is the address the clinic gives its patients, and DNS publishes it anyway.
Joining. On every host — a clinic's own domain and its platform subdomain alike — a signed-in person who is not yet a patient joins on /welcome: name, phone and date of birth (only what their profile lacks; asked, never read from Clerk — a login is only a login), then each required document on its own screen, the platform's and then the clinic's. See Onboarding & Legacy Linking → A.
Links in emails. Every patient-facing email link — program prescribed, report ready, re-consent, access expiring, video consultation starting, form session links, a front-desk email change — opens on the same host: the clinic's first sign-in-ready domain (alphabetical, same predicate as auth_verified_domains), else {slug}.portal.restartix.pro. One resolver (internal/core/portallink) owns the rule, reading on the admin pool because most senders are crons with no org context. Staff invitations are not yet covered: the Clerk invitation ticket is consumed on the primary.
Admin UI. The Console and Clinic domain lists carry a Patient sign-in column for portal domains; Verify stays on offer until both halves are done.
Local development. scripts/seed-local-demo-domain.sh seeds demo.localhost:9200 as a verified portal domain and sign-in ready, so the Portal runs Clerk there as a satellite of demo.portal.localhost:9200 (the Clerk development instance must list the domain as a satellite). AUTH_READY=0 leaves it unstamped, to exercise the redirect branch.
Rollout. auth_verified_at sits in 000002's CREATE TABLE; local and production took it by catch-up script (infra/scripts/000002-domain-auth-verified.sql). Every existing row is NULL until Verify runs under the new code, so after the deploy press Verify on each portal domain — until then the Portal sends that domain's patients to the platform subdomain.
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.)