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.
  • 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.AddDomaincloudflaresaas.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.tsresolveOrganizationByDomain(host) → finds the verified org → serves it.
  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. VerifyDomaincloudflaresaas.GetStatus → maps CF status → flips pending → 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 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.

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.)