ADR-022: Custom domains through Cloudflare for SaaS
Status: accepted, 8 October 2026.
Context
Section titled “Context”Every shop has lived at {slug}.flowery.com.ua. Shops that already own a domain (myflowers.ua) and have printed it on cards and packaging cannot put their website on it, and the landing page has had to say so. The platform runs on one shared Forge server behind Cloudflare, with one *.flowery.com.ua certificate; it cannot hold a certificate for a hostname it does not control.
Decision
Section titled “Decision”- Cloudflare for SaaS custom hostnames. The
flowery.com.uazone is already on Cloudflare. Each connected domain becomes a custom hostname there: Cloudflare proves ownership (aTXTrecord), issues and renews the certificate, and proxies the traffic to our fallback origin. Shops point aCNAMEatconnect.flowery.com.ua. The first hundred hostnames are included in the plan; each one beyond costs $0.10 a month. The alternative, issuing Let’s Encrypt certificates per hostname on the origin, would put certificate issuance, renewal and a reverse proxy per host into our own operations on a shared server. - The storefront only. A shop’s own domain serves the storefront pages and the Storefront API, nothing else. The control panel, sign-in and the session cookie (
.flowery.com.ua, ADR-006) stay on the subdomain, and the shop keeps its subdomain address throughout. The storefront and API routes are registered a second time on a host pattern that can never match the platform domain or a host under it, outside thewebgroup; and thewebgroup itself answers only on platform hosts, so sign-in and everything else are a 404 on a foreign host before a session is started. - One address per shop.
GET /shopnames the host customers use: the shop’s own domain once it is live, otherwise the subdomain. The storefront runtime answers a visit on any other host with a permanent redirect to it, except previews.Workspace::storefrontHost()is the one place that decides this;Workspace::controlPanelUrl()always builds subdomain links for staff. - Origin TLS. Cloudflare forwards the shop’s hostname unchanged in the
Hostheader and the TLS name; overriding theHostheader is an Enterprise feature. The origin presents its*.flowery.com.uacertificate (Let’s Encrypt, issued by Forge) for every host, so the zone must not check the certificate’s name for foreign hosts: the zone runs in encryption mode “Full” (a certificate is required, its name is not checked), which it already did when this was decided. Should the zone move to “Full (strict)” for its own hosts, a Configuration Rule keeps hosts not underflowery.com.uaon “Full”. The storefront runtime’s loopback API calls present and verify one fixed name the certificate covers (flowery.com.ua) while keeping the shop’sHost. - With or without
www: the owner’s choice. (Amended 10 and 12 October 2026; the first version recommendedwww.and showed aTXTrecord as well; the second registered the twin unasked, which producedwww.shop.kolovsky.devfor a subdomain in the first real run.) The owner types the address as the shop calls it; for a bare domain (shop.ua,shop.com.ua) or awww.name the form offers its twin as a checkbox, ticked by default, and a subdomain gets none. Both hostnames go to Cloudflare when the twin is chosen, so the site works whichever record the registrar can set:wwwis a plainCNAMEeverywhere, while a bare domain needs a provider that accepts aCNAME(or ALIAS) at the root, which Cloudflare DNS does and many registrars do not, since proxying bare domains at Cloudflare itself is an Enterprise feature. The shop is live as soon as either hostname is; the address customers use is the typed one when it is live, otherwise its twin, and the storefront redirects the other there. Ownership is validated from theCNAMEitself (Cloudflare’s real-time validation); theTXTpre-validation record, which only serves zero-downtime moves of an existing site, is no longer shown. Two hostnames per shop halve the included quota to fifty shops. - Owner only, no plan gate. Connecting, checking and removing a domain needs the
settings.domainpermission, which only the owner has. One domain per workspace. Billing can gate it later. - Polling, not webhooks. A hostname’s state (pending, active, moved) has no webhook at Cloudflare; only certificate events do. A scheduled command checks pending domains every few minutes and live ones daily, and the settings page has a “check now” button.
- The records are added for the shop where we can (11 October 2026). The page names the domain’s DNS provider from its name servers and links the provider’s CNAME help where known. When the DNS is on Cloudflare, “Connect with Cloudflare” sends the owner to Cloudflare’s consent screen for our OAuth client (self-service at Cloudflare; the authorization-code flow with PKCE); with the short-lived token the app adds a DNS-only
CNAMEto the target for each address of the pair in the owner’s zone, replacing anyA/AAAA/CNAMEalready on that exact name and saying so, then revokes the token. Nothing of the owner’s is stored. The same one-click flow for every provider that supports the Domain Connect standard (Cloudflare, GoDaddy, IONOS, …) is item 9; the OAuth path stays for Cloudflare because it can replace conflicting records, which Domain Connect cannot. - Domain Connect (12 October 2026). Our template
flowery.com.ua / storefrontlives in the standard’s public repository: oneCNAMEon a host (www, or a subdomain) toconnect.flowery.com.ua,hostRequired, because the standard forbids aCNAMEon a bare apex — which is the limit we already tell shops about. Requests are signed with our RS256 key (private half only in the environment, public half asTXT _dcpubkeyv1.flowery.com.ua; rotation is a new id). The app finds the provider from the zone’s_domainconnectrecord, confirms it serves the template, and sends the owner to the provider’s own page; the owner signs in there, approves one record, and is sent back. No token ever reaches us. The provider’s answer is not trusted on its own: the address is checked against Cloudflare at once, as every other path is. Cloudflare exposes a provider’s template only after an onboarding by email; other providers read the repository.
Consequences
Section titled “Consequences”workspace_domains: one row per hostname, two per shop (the typed address marked primary), with the hostname (lowercase punycode), its state (pending,active,moved,error), the Cloudflare id and timestamps. Onlyactiveresolves a request. The never-usedworkspaces.domaincolumn is gone.IdentifyTenantresolves a host that is not under the platform domain by an activeworkspace_domainsrow; the root domain has no tenant, as before.- The nginx site answers any hostname no other site on the shared server claims, the storefront map treats every host but the root and
wwwas a storefront, and nginx takes the visitor’s address fromCF-Connecting-IP(operations). All traffic already came through Cloudflare, so the last change also repairs Storefront API rate limiting, which had put every visitor in one bucket. - The cart cookie is host-only, so a visitor who followed an old subdomain link starts with an empty cart on the shop’s domain. Cart merging was already deferred.
Shop.domainjoins the Storefront API contract; themes need no change becauseoriginis already the shop’s address.- The shop keeps its subdomain, so removing a domain never takes the shop offline: links on the old domain stop working, and the subdomain answers again at once.