Custom Domains
Bring-your-own hostnames, DNS verification & SSL
The domains block lets your tenants attach their own hostnames — an apex like band.com, its www.band.com, or a subdomain such as shop.band.com — on top of the platform subdomain they already get. Each hostname is proved-owned via live DNS, provisioned with TLS, and then resolves straight to the owning tenant.
Ownership is verified against real DNS (node:dns TXT/CNAME lookups), certificates come either from your platform ingress (manual DNS) or Cloudflare for SaaS, and an optional Domain Concierge flow can register brand-new domains on a tenant's behalf. Everything sits on multi-tenant mode and lives in the control-plane schema.
Custom hostnames#
A tenant on your platform already reaches their space at a platform subdomain (acme.platform.com). Custom domains add vanity hostnames the tenant owns — apex, www and arbitrary subdomains — that route to the same tenant once ownership is proven. Every hostname is classified so the framework knows how to verify and route it.
apexband.comOptionalA root domain (two labels, no leading www). Ownership is proven with a TXT record; routing points an A record at your ingress and, because apex A-records can't be reliably auto-probed, usually needs the explicit activate step after verification.
wwwwww.band.comOptionalThe conventional www host. Gated by allowWwwDomains. Verified by TXT and routed by CNAME to your cnameTarget.
subdomainshop.band.comOptionalAny other sub-host the tenant controls. Verified by TXT and routed by CNAME, so it can auto-activate once the CNAME resolves to the target.
platform_subdomainacme.platform.comOptionalA host under your own platformBaseDomain. Detected as first-party (not a customer domain) so it is never treated as an apex the tenant must verify.
config.domains#
One block turns the whole capability on. Choose a provider, tell the framework your platform base domain and the DNS target customer hostnames should point at, then decide which hostname shapes and flows are allowed. cnameTarget and every cloudflare.* field are env-name-or-literal: if an env var of that name is set it wins, otherwise the literal value is used.
1{2 "database": { "url": "DATABASE_URL", "isMultiTenant": true },3 "domains": {4 "enabled": true,5 "basePath": "/domains",6 "provider": "cloudflare_for_saas",7 "platformBaseDomain": "platform.com",8 "cnameTarget": "customers.platform.com",9 "allowApexDomains": true,10 "allowWwwDomains": true,11 "allowManagedRegistrations": false,12 "reservedHostnames": ["www.platform.com", "api.platform.com"],13 "verification": {14 "defaultMethod": "txt",15 "txtRecordPrefix": "_nucleus-challenge",16 "challengeTtlSeconds": 60480017 },18 "cloudflare": {19 "accountId": "CF_ACCOUNT_ID",20 "zoneId": "CF_ZONE_ID",21 "apiToken": "CF_API_TOKEN",22 "fallbackOrigin": "origin.platform.com",23 "cnameTarget": "customers.platform.com"24 },25 "registrar": {26 "provider": "cloudflare_registrar",27 "defaultRegistrantOwnerType": "customer"28 }29 }30}enabledbooleanOptionalMaster switch. When false nothing registers — no routes, no tables, no tenant-resolution indexing.
falsebasePathstringOptionalRoute prefix the domain endpoints mount under (e.g. /domains/hostnames, /domains/resolve).
"/domains"provider"manual_dns" | "cloudflare_for_saas"OptionalHow hostnames are verified and given TLS. manual_dns needs no third-party API; cloudflare_for_saas issues managed certificates. If cloudflare_for_saas is selected but the cloudflare block is incomplete, the framework logs a warning and falls back to manual DNS rather than crashing.
manual_dns— No external API. Issues DNS instructions and verifies ownership against live DNS; TLS is assumed terminated by your ingress.cloudflare_for_saas— Creates Cloudflare custom hostnames and DV certificates, tracking activation and cert issuance as two independent lifecycles.
"manual_dns"platformBaseDomainstringOptionalYour own base domain, e.g. "platform.com". Enables exact platform-subdomain detection and auto-reserves the apex so no tenant can claim it.
cnameTargetstringOptionalEnv-name-or-literal. The CNAME/A target customer hostnames point at (your ingress). Manual DNS uses this to build routing instructions and to probe live CNAMEs for auto-activation. If left unset, the resolved target falls back to cloudflare.cnameTarget.
Example: "customers.platform.com"
allowApexDomainsbooleanOptionalWhether tenants may attach apex (root) domains. When false, an apex-kind hostname is rejected at create time.
trueallowWwwDomainsbooleanOptionalWhether tenants may attach www hostnames. When false, a www-kind hostname is rejected at create time.
trueallowManagedRegistrationsbooleanOptionalEnables the Domain Concierge flow — requesting brand-new domain registrations through the API. Off by default; registration requests throw when disabled.
falsereservedHostnamesstring[]OptionalHostnames tenants may never claim (normalized on load). createHostname rejects any match, alongside the platformBaseDomain itself.
Example: ["www.platform.com", "api.platform.com"]
verificationobjectOptionalOwnership-challenge tuning.
defaultMethod"txt" | "cname" | "http"OptionalInformational only — resolved but not consumed by the providers. Manual DNS always issues a TXT challenge; Cloudflare for SaaS uses TXT DV. Setting cname/http here does not change behavior.
"txt"txtRecordPrefixstringOptionalSets the NAME prefix of the manual-DNS TXT challenge record: <txtRecordPrefix>.<hostname>. It does not affect the record value.
"_nucleus-challenge"challengeTtlSecondsnumberOptionalHow long an ownership challenge is valid before it expires. 604800 = 7 days.
604800cloudflareobjectOptionalCloudflare for SaaS credentials. Each field is env-name-or-literal. The block resolves to null (and the provider falls back to manual DNS) unless accountId, zoneId and apiToken all resolve to non-empty values.
accountIdstringRequiredCloudflare account id (env name or literal).
zoneIdstringRequiredZone the custom hostnames are created under (env name or literal).
apiTokenstringRequiredAPI token with custom-hostname scope, sent as a Bearer token (env name or literal).
fallbackOriginstringOptionalOptional Cloudflare for SaaS fallback origin. Informational only — resolved into config but currently NOT sent to Cloudflare (createCustomHostname posts just { hostname, ssl }), so setting it has no runtime effect (inert), like verification.defaultMethod.
cnameTargetstringOptionalFor the Cloudflare provider, cloudflare.cnameTarget takes precedence over the top-level cnameTarget. It also serves as a fallback for the resolved top-level target when the top-level cnameTarget is unset, in which case Manual DNS uses it too.
registrarobjectOptionalManaged-registration (Domain Concierge) settings.
provider"cloudflare_registrar"OptionalRegistrar backend. Currently the only value; stamped onto new registration records.
"cloudflare_registrar"defaultRegistrantOwnerType"customer" | "platform"OptionalDefault legal registrant when a request does not specify one. platform (the platform is the registrant / renewal payer) is godmin-only at the route.
"customer"DNS ownership verification#
Before a hostname can carry traffic, the tenant must prove they control it. The framework hands back the exact DNS records to add and then checks them against live DNS with node:dns — no polling service or webhook needed. Call verify (or refresh-status) to re-run the check and recompute readiness.
TXT challengeresolveTxtOptionalManual DNS always issues a TXT record. Name is <txtRecordPrefix>.<normalizedHostname>, value is a fixed nucleus-domain-verification=<48-hex-char token>. A live resolveTxt lookup that finds the token flips verification_status to verified.
CNAME routing proberesolveCnameOptionalFor non-apex hostnames with a cnameTarget set, a live resolveCname probe checks the host points at your target. A match sets routing_status active; otherwise it stays pending until the record propagates.
apex routingactivateOptionalApex domains route via an A record, which cannot be auto-probed. After TXT verification succeeds, an admin activate call sets routing active — this is the expected extra step for apex hostnames on manual DNS.
challenge lifecyclestatusOptionalVerification status can be pending, verified or failed; 'expired' is a defined status but the framework never auto-transitions to it. Each challenge row stores an expiry timestamp (derived from challengeTtlSeconds), yet nothing enforces it — loadPendingChallenge selects the still-pending challenge with no expiry filter, so re-running verify simply re-checks live DNS. A freshly created hostname starts at 'pending_verification' (its status before any verify call); it only moves to 'verifying' after a verify / refresh-status call whose result isn't yet ready and hasn't failed. Failed verification moves the hostname to failed; a not-yet-ready-but-not-failed hostname sits in verifying. Instructions are idempotent — getInstructions rebuilds the records from dns_target plus the hostname's challenge rows (queried by hostname id, regardless of status) any time you need to re-show the steps.
SSL provisioning#
Certificates are handled by whichever provider you chose. Manual DNS leans on your platform ingress to terminate TLS, while Cloudflare for SaaS issues and tracks a managed DV certificate per hostname.
manual_dnsnot_requiredOptionalTLS is assumed terminated at your platform ingress, so certificate_status is not_required from the start. The framework only proves ownership and points routing at cnameTarget; you bring the wildcard/edge certificate.
cloudflare_for_saasdv certificateOptionalCreates a Cloudflare custom hostname with an SSL DV certificate (min TLS 1.2) and tracks two independent lifecycles: hostname activation (result.status) and certificate issuance (result.ssl.status). Blocked/moved/deleted marks routing failed; a validation-timed-out, deactivating OR deleted SSL status marks the certificate failed, and ssl validation errors (validation_errors > 0) mark verification failed.
validation recordscloudflare-ownedOptionalCloudflare for SaaS uses its own ownership_verification and ssl.validation_records — not the _nucleus-challenge TXT — so the DNS records you surface to the tenant come from Cloudflare, not the manual TXT challenge.
Full-hostname tenant resolution#
Once a hostname is active it resolves straight to its tenant. The tenant registry matches the full normalized hostname first — ahead of subdomain, header and main-schema fallback — so band.com, www.band.com and shop.band.com all land on the right tenant regardless of your base tenantResolution strategy.
startup indexloadDomainHostnamesOptionalAt boot the registry indexes every domain_hostnames row where status is active and whose tenant_id maps to a known tenant. It is a safe no-op if the table is absent, and a hostname with no matching tenant is simply skipped.
live updatesregister / unregisterOptionalThe index stays live: activate / verify-success calls registerDomainHostname and disable calls unregisterDomainHostname, so newly-verified hostnames start resolving immediately and disabled ones stop.
GET /domains/resolvepublicOptionalGiven a hostname (or the request host header) returns { hostnameId, tenantId, schemaName, isPrimary, redirectToHostname } for the active match — the primitive an edge/proxy uses to route a raw hostname to a tenant. 400 if no hostname, 404 if no active match.
Managed registration (Domain Concierge)#
Beyond attaching hostnames tenants already own, the optional Domain Concierge flow requests registration of brand-new domains. It is gated by allowManagedRegistrations. The service owns the lifecycle and audit trail only — it never fabricates availability or pricing; the actual registrar binding is driven by your deployment calling transition().
requestPOST /domains/registrationsOptionalBody { requestedDomain, ownerId, ownerType?, tenantId?, registrantOwnerType?, termsVersion (required), termsAccepted (required true), autoRenew? }. ownerType is one of 'tenant' | 'organization' | 'user' | 'external' and defaults to 'tenant' when omitted. termsAccepted must be truthy or the request throws.
lifecycleawaiting_payment → activeOptionalNew records start at awaiting_payment (not draft), stamped with the registrar provider and registrantOwnerType. This shows the common path; transition() accepts any RegistrationStatus, so records may move through availability_checked, registering, active, failed, transfer_requested, transferred_out, cancelled and expired.
transition()deployment-drivenOptionalYour deployment advances a registration by calling transition(id, status, patch?) with { registeredDomain, providerRegistrationId, priceAmount, currency, renewalDate, failureReason }. Each transition emits a registration_<status> event. cancel sets cancelled; requestTransferOut sets transfer_requested.
Lifecycle routes#
All endpoints mount under basePath (default /domains) and return a { success, message, data } envelope. Auth uses the x-user-id header (401 without it); a 503 means the service isn't ready. Only GET /domains/resolve is public. As of v0.9.746 all five domain tables (domain_hostnames, domain_registrations, domain_provider_records, domain_verification_challenges, domain_events) are classified sensitive system tables, so generic-entity-CRUD WRITES to them require godmin (a non-godmin write returns 403) — tenant callers MUST go through these lifecycle routes, which derive tenant/ownership server-side.
GET /domains/resolve?hostname=publicFull-hostname → tenant lookup. Falls back to the host header; 400 if none, 404 if no active match.
POST /domains/hostnamesx-user-idCreate a hostname ({ hostname, ownerId, ownerType?, tenantId?, schemaName?, isPrimary? }) → { hostname, instructions }. ownerType is one of 'tenant' | 'organization' | 'user' | 'external' and defaults to 'tenant' when omitted. 400 on an invalid, reserved or duplicate hostname. Non-godmin callers have tenantId and schemaName forced to their own verified tenant.
GET /domains/hostnamesx-user-idList hostnames. Non-godmin callers are pinned to their own tenant (client tenantId/ownerId filters ignored); godmin may filter by tenantId, ownerId, or list all.
GET /domains/hostnames/:idx-user-idFetch one. 404 if missing or cross-tenant (existence not confirmed for other tenants).
GET /domains/hostnames/:id/instructionsx-user-idRebuild the DNS records to apply (A/CNAME target plus challenge rows). Idempotent; 404 if cross-tenant.
POST /domains/hostnames/:id/verifyx-user-idRun ownership + provider status re-sync and recompute readiness. 404 if cross-tenant.
POST /domains/hostnames/:id/activatex-user-idAdmin force-activate. Requires verification_status='verified' (else 400); the expected step for apex hostnames on manual DNS. 404 if cross-tenant.
POST /domains/hostnames/:id/disablex-user-idDelete the provider hostname, set status disabled and remove it from the resolution index. 404 if cross-tenant.
POST /domains/hostnames/:id/make-primaryx-user-idMark this hostname primary, clearing the flag on others in the same tenant/owner scope. 404 if cross-tenant.
POST /domains/hostnames/:id/refresh-statusx-user-idAlias of verify. 404 if cross-tenant.
POST /domains/registrationsx-user-idRequest a managed registration (Domain Concierge). Non-godmin tenantId is bound to the caller and registrantOwnerType forced to 'customer'.
GET /domains/registrationsx-user-idList registrations. Non-godmin callers are pinned to their own tenant (filters ignored).
GET /domains/registrations/:idx-user-idFetch one registration. 404 if missing or cross-tenant.
POST /domains/registrations/:id/cancelx-user-idMove the registration to status cancelled. 404 if cross-tenant.
POST /domains/registrations/:id/transfer-outx-user-idMove the registration to status transfer_requested. 404 if cross-tenant.
Tenant binding & ownership (v0.9.744+)#
Because a live custom hostname routes traffic to a tenant's schema, who a hostname is bound to is a security decision, not a convenience field. As of v0.9.744 a non-godmin caller can only ever bind, see and act on hostnames within their own verified tenant. Godmin is exempt and may provision on behalf of any tenant.
server-derived binding on createtenantId + schemaNameOptionalOn POST /domains/hostnames a non-godmin caller has BOTH tenantId and schemaName derived server-side from the request's verified x-tenant-schema (resolveTenantIdForSchema). A body schemaName naming a different tenant → 403 "Cannot bind a hostname to another tenant's schema"; a body tenantId naming a different tenant → 403 "Cannot bind a hostname to another tenant". Guarding schemaName alone was insufficient because tenantId is the actual routing key.
tenant-scoped reads & lifecycle404, not 403OptionalList ignores client tenantId/ownerId filters and pins to the caller's tenant. GET :id, instructions, and every lifecycle op (verify / activate / disable / make-primary / refresh-status) load and ownership-check the record before mutating, returning 404 "Hostname not found" on a cross-tenant id so existence is never confirmed.
registrations are tenant-scopedplatform = godmin-onlyOptionalPOST /domains/registrations binds tenantId to the caller's verified tenant (foreign → 403 "Cannot register a domain for another tenant") and forces registrantOwnerType:'customer'. Requesting 'platform' (billing the platform as renewal payer) → 403 "Only a platform admin may register as 'platform'". List pins to the caller's tenant; GET :id, cancel and transfer-out return 404 cross-tenant.
Related sections