Skip to main content
Petanque Life

Custom Domains

F18.14 8 features Planned

At a glance

Custom domain support powered by Cloudflare for SaaS — clubs and federations attach their own domain (www.federation.fr, klubb.se), copy-paste DNS instructions, and get auto-provisioned and auto-renewed DV TLS within minutes. A reconciliation job polls every five minutes through pending → pending_validation → active states, the admin UI surfaces clear status badges and copy buttons, and a wildcard `*.web.petanque.life` subdomain stays available as a permanent fallback.

How it works

When a tenant adds a custom domain, the API creates a CmsDomain document and calls the Cloudflare Custom Hostnames API to register the hostname against the platform's SaaS zone. Cloudflare returns DNS instructions: a CNAME pointing the customer hostname at `*.web.petanque.life` and a TXT record for ownership proof. The admin UI presents these in copy-ready boxes with a one-click copy button so non-technical editors can hand them to their registrar without confusion.

Status flows through a state machine: `pending` (just created, waiting for DNS) → `pending_validation` (CNAME and TXT detected, awaiting Cloudflare's validation) → `active` (certificate issued, traffic flowing). A reconciliation job polls the Cloudflare API every five minutes for any domain not in a terminal state, updates local status, and marks domains as `failed` after a 72-hour timeout so stale entries don't accumulate. Cloudflare handles certificate issuance via free DV certs and automatic renewal — the platform never touches private keys.

The renderer's middleware looks up the incoming Host header against the CmsDomain collection, resolves it to a CmsSite, and serves the right tenant's content; the same site can have multiple custom domains plus its platform subdomain pointing at the same content. The wildcard `*.web.petanque.life` fallback works without any Cloudflare registration, so a freshly created site is reachable instantly while the editor optionally configures a custom domain. Admin can remove a domain at any time; the system deletes the Cloudflare hostname and the next request to that domain falls back to the platform subdomain.

Audit trail captures every domain action — registration, validation, removal — for security and compliance review.

Key capabilities

  • Add custom hostname with one API call to Cloudflare Custom Hostnames
  • Editor-friendly DNS instructions (CNAME + TXT) with copy buttons
  • State machine: pending → pending_validation → active with 72h failure timeout
  • Auto-provisioned and auto-renewed DV TLS certificates via Cloudflare
  • Background reconciliation job polling every five minutes
  • Multiple domains per site, all resolving to the same content
  • Wildcard `*.web.petanque.life` fallback always available, no DNS required

In practice

Erik, secretary at Norrlands BK, decides the club is ready to move from norrlands-bk.petanque-life.com to its newly-purchased domain norrlandbk.se. He opens the Custom Domains tab, types the domain, and clicks 'Add'. The status flips to pending and two boxes appear with the CNAME and TXT records.

He emails them to his registrar's support since he doesn't run his own DNS. Three hours later DNS propagates; the reconciliation job picks it up on its next poll and the status moves to pending_validation. Six minutes later Cloudflare confirms ownership, issues the certificate and the status flips to active.

Erik visits norrlandbk.se in his browser; the site loads with a green padlock. The platform subdomain still resolves so any printed material with the old URL keeps working forever.

Features in this subsystem

8
ID Status Features
F18.14.01 Shipped Custom hostname-registrering med CNAME-instruktioner ✅ 449770bd.m-4 — cms_domain (migration 00203) + POST /v1/cms/sites/{id}/domains med Idempotency-Key; instruktionerna är serverns (CNAME mot PL_CMS_EDGE_CNAME_TARGET + TXT _petanque-verify.<host> = pl-verify=<token>), aldrig en literal i klienten. Global unikhet bland ej raderade via partiellt unikt index; 409 hostname_taken, 400 invalid_hostname, 400 hostname_in_platform_zone
F18.14.02 Shipped CNAME/TXT-valideringsflöde ✅ 449770bd.m-4 — POST …/domains/{id}:verify och svepet delar EN dom (cms.AssessDomainProof, P-11): verified|partial|none|wrong_target, där wrong_target bär både observerat och förväntat mål. Statusmaskinen pending → pending_validation → active|failed (failed inte terminalt) går genom cms.TransitionDomainStatus — den enda sömmen. Ett resolver-fel stämplar last_checked_at men lämnar status orörd och skriver noll audit
F18.14.03 Planned Automatisk TLS-provisionering ⊘ go-live (C-3/C-4) — edgen (Caddy on-demand TLS) och dess certcache är infrastruktur, inte applikationskod; initiativets uttalade out-of-scope. Plattformens del är F18.14.04. ssl_status/edge-hostname-id byggs inte (D-1): ett fält utan skrivare är en lögn om att vi vet något
F18.14.04 Shipped Cert-utfärdande gate mot Let's Encrypt-abuse ✅ 449770bd.m-4 — GET /v1/public/cms/domains/cert-allowed?domain=, deny-by-default och indexbackad: 200 endast för aktiv sajts plattforms-subdomän i zonen eller active custom-domän på aktiv sajt, allt annat 404. Cache-Control: no-store och ingen processcache — en :verify-flip syns utan omstart
F18.14.05 Planned Admin-UI för domänhantering (add/verify/remove, statusbadges, kopierbar CNAME-target) ⊘ 449770bd.m-4 t-2 (domänguiden i admin) — t-1 levererar serverkontraktet
F18.14.06 Shipped Plattform-subdomän-konvention <slug>.<zon> med auto-DNS ✅ 449770bd.m-4 — sex additiva cms_site-kolumner (subdomain_dns_status|_error|_provisioned_at|_attempts, mount_parent_site_id, mount_path_slug). Requesten väntar aldrig på en DNS-leverantör: wizard/PATCH skriver bara pending, zonen rörs av svepet cms_domain_reconcile (*/5 * * * *, försökstak 8) eller av POST …:repair-dns (synkron, idempotent, nollställer taket, 422 utan subdomän). Adaptern väljs av miljön (PL_CMS_DNS_BACKEND); utan adapter hoppar svepet ärligt över med en logg
F18.14.07 Shipped Hostname-resolve för edge-routing ✅ 449770bd.m-4 — GET /v1/public/cms/domains/resolve/{hostname} med egen publik projektion, match ∈ {platform_subdomain, custom_domain}, monteringar djupast först, max-age=60 + svag ETag/304, och fail-closed 404 utan formskillnad (okänt namn, pending/raderad domän, setup/inaktiv sajt ger samma kropp — annars läcker domänkatalogen)
F18.14.08 Shipped Montering: barnsajt under förälderns värd (/<distrikt>/<klubb>/) — stänger F18.01.08 ✅ 449770bd.m-4 — PUT/DELETE /v1/cms/sites/{id}/mount; composed_path och active härleds vid läsning, lagras aldrig (en lagrad väg blir osann när en förälder byter slug). Max 3 nivåer, reservlistan som data (_next, account, api, book, proxy, rss, sitemap, sites), 409 mount_slug_taken, 422 mount_cycle, 422 mount_too_deep. cms.ResolveMountMatch delas av resolven och m-5:s renderare