Skip to content

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.ro was 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 plane 4ff8be9, data plane (dynamic CORS + ALB catch-all) 8a8c32e, staging CNAME-target wiring 4964709, hardcoded-platforma removal 8725a71.
  • 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. AddDomain rejects domain_type=clinic (clinic_custom_domains_unavailable, commit bb9450a) 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 carries domain_type.

Architecture — the proven portal path ​

  1. Register. Admin adds a domain (Console/Clinic → Domains, route gated on RequireTierEntitlement("custom_domain")) → organization.AddDomain → cloudflaresaas.Register (Cloudflare Custom Hostnames API) → stores cloudflare_hostname_id + ssl_status on organization_domains (cols added to migration 000002) → returns the CNAME instruction: CNAME {domain} → CLOUDFLARE_SAAS_CNAME_TARGET (= customers.restartix.pro).
  2. Clinic CNAMEs their domain → customers.restartix.pro (a proxied record in the restartix.pro zone). 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).
  3. 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).
  4. 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-priority host_header rules first.
  5. 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).
  6. CORS. middleware.CORS consults organization.NewCORSOriginAllower (verified organization_domains lookup, Redis negative-cached 60s) only on a static-allowlist miss → custom domains pass cross-origin with zero env-list entries.
  7. Status refresh. VerifyDomain → cloudflaresaas.GetStatus → maps CF status → flips pending → verified (the Verify button; a sweep cron is a deferred follow-up). The same call re-checks patient sign-in and stamps or clears auth_verified_at.

Config + one-time setup ​

  • api task def env: CLOUDFLARE_SAAS_API_TOKEN + CLOUDFLARE_ZONE_ID (from SM restartix/{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 proxied customers.restartix.pro CNAME target. The API token needs Zone: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.pro is shared staging+prod. Set it to portal-staging for 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:

  1. Add the domain in the Clerk dashboard → Domains → Satellites (the production instance). Satellites are paid per domain, per Clerk pricing.
  2. 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.
  3. 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):

HostClerk
{slug}.portal.restartix.proPrimary. 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_domainsSatellite — 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 listNone. 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. AddDomain rejects domain_type=clinic (commit bb9450a) 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:

  1. Reads the incoming Host (the custom domain).
  2. Resolves domain_type (clinic | portal) via GET /v1/public/organizations/resolve?domain=X (extend the response to include domain_type), cached in Workers KV.
  3. 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 in X-Forwarded-Host for the app's resolve.

Required changes:

  • proxy.ts (clinic and portal): resolve-by-domain from X-Forwarded-Host (fall back to Host).
  • resolve endpoint: add domain_type to the public response (the Worker needs it).
  • cloudflaresaas: optionally set a per-hostname custom origin = {clinic|portal}-{env}.restartix.pro per domain_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 VerifyDomain is UI-triggered; a scheduled sweep of pending hostnames 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 api task def already pulls CLOUDFLARE_SAAS_API_TOKEN + CLOUDFLARE_ZONE_ID from Secrets Manager; CLOUDFLARE_SAAS_CNAME_TARGET was the one gap, now wired in infra/envs/production/compute.tf. The remaining steps are out-of-band at prod-provisioning time (Cloudflare-for-SaaS fallback origin + customers.restartix.pro CNAME on the restartix.pro zone) — 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.)