Custom domains
A shop can serve its storefront from a domain it owns (ADR-022): the domain becomes a Cloudflare for SaaS custom hostname, Cloudflare holds its certificate and proxies to our origin, and the shop points a CNAME at connect.flowery.com.ua. The shop keeps its {slug}.flowery.com.ua address: staff sign in there, previews open there, and the storefront there redirects customers to the domain once it is live.
One-time setup
Section titled “One-time setup”Done by the owner in Cloudflare and Forge; nothing here is automated.
Cloudflare, zone flowery.com.ua
- Enable custom hostnames. SSL/TLS → Custom Hostnames → Enable. A card on file is required even inside the included quota: 100 hostnames are included, each one beyond is $0.10 a month. The quota is a soft limit;
GET /zones/{zone}/custom_hostnames/quotareportsusedandallocated. - Fallback origin. A proxied
Arecordshops-origin.flowery.com.ua→ the origin server’s address, designated as the fallback origin under Custom Hostnames. Cloudflare routes every custom hostname there. - CNAME target. A proxied
CNAMEconnect.flowery.com.ua→shops-origin.flowery.com.ua. This is the name shops put in their DNS, so it can change where it points without any shop changing anything. Both names are reserved shop addresses (WorkspaceSlug::RESERVED). - Encryption mode for foreign hosts. Rules → Configuration Rules: expression
not (http.host ends with "flowery.com.ua"), setting SSL/TLS encryption mode = Full. The origin only has the*.flowery.com.uacertificate; “Full (strict)” would check its name against the shop’s hostname and answer 526. The zone itself stays on Full (strict). - API token. A token scoped to this zone with SSL and Certificates: Edit, for the application (next pull request):
CLOUDFLARE_API_TOKEN,CLOUDFLARE_ZONE_IDandCUSTOM_DOMAINS_CNAME_TARGET=connect.flowery.com.uain the site’s environment.
Forge, site flowery.com.ua — the four server-side steps in Storefront → Shops’ own domains: the server_name pattern, the storefront map’s default, the CF-Connecting-IP lines, and STOREFRONT_API_SNI in storefront.env. The nginx file must keep its full shape (the 5 October 2026 incident on that page); confirm the reload afterwards.
State on 8 October 2026. Steps 1–3 and the Forge steps are done and verified; step 4 turned out unnecessary because the zone was already on “Full”. The certificate at the origin is Forge’s Let’s Encrypt one for flowery.com.ua and *.flowery.com.ua, so the runtime pins flowery.com.ua. Step 5, the token, is set when the first shop needs it.
Checks, read-only, on the server
# A foreign host lands in Flowery's server block: the storefront's "shop not found" page, not another site's.curl -sk --resolve www.example-shop.ua:443:127.0.0.1 -o /dev/null -w '%{http_code}\n' https://www.example-shop.ua/# The certificate covers the runtime's TLS name.openssl x509 -in /etc/nginx/ssl/domains/<id>/<id>/server.crt -noout -ext subjectAltNameConnecting a domain (Settings → Domain)
Section titled “Connecting a domain (Settings → Domain)”Owner only (settings.domain). The owner types the address as the shop calls it (a pasted URL is forgiven; квіти.ua is stored as punycode and shown in Unicode). For a bare domain or a www. name the form offers the twin (shop.ua ↔ www.shop.ua) as a checkbox, ticked by default; a subdomain such as shop.kolovsky.dev stands alone. The app registers the chosen hostnames at Cloudflare, and the page shows one record per address with its own state:
www→CNAME→connect.flowery.com.ua: works at every provider;@(the bare domain) →CNAME→connect.flowery.com.ua: works where the provider accepts aCNAMEor ALIAS at the root (Cloudflare DNS does; many registrars do not). The page says: if yours refuses it, redirect the bare domain towwwat the registrar instead.
Ownership is validated from the CNAME itself, so there is no TXT step (the ownership_verification values Cloudflare returns are still stored, for a later zero-downtime move option). Refused: the platform domain and anything under it, the CNAME target, and a hostname another shop holds under either form. If Cloudflare refuses the twin, the typed address alone is kept. Cloudflare’s own error text goes to the log; the owner sees a translated sentence.
domains:check polls every five minutes (hostnames not yet live every four minutes, live ones daily, --all for everything); “Check now” on the page does the same for the shop’s pair. The shop is live as soon as either hostname reports ownership and the certificate active: the owner gets one email, customer links switch to the typed address when it is live (otherwise the twin), and the storefront redirects every other host there. “Remove domain” deletes both hostnames at Cloudflare and the rows; the subdomain serves the shop again at once. Connecting a different domain replaces the previous pair the same way.
How a domain goes live
Section titled “How a domain goes live”A domain’s row in workspace_domains carries its state:
| State | Meaning | What resolves |
|---|---|---|
pending |
Created at Cloudflare; waiting for the shop’s TXT (ownership) and CNAME (traffic and certificate) records |
Nothing: the hostname is a 404 |
active |
Ownership proven, certificate issued, DNS pointing at us | The storefront and the Storefront API on that host; the subdomain’s storefront redirects there |
moved |
Was live; the shop’s DNS no longer points at connect.flowery.com.ua |
Nothing; the subdomain serves the shop again |
error |
Cloudflare refused or gave up (last_error says why) |
Nothing |
Cloudflare reports two things separately: hostname ownership (status) and the certificate (ssl.status). A domain is live only when both are active. There is no webhook for the hostname state, so the application polls (next pull request). While a domain is not live, nothing changes for the shop.
Adding the records for the shop: “Connect with Cloudflare”
Section titled “Adding the records for the shop: “Connect with Cloudflare””The page detects who hosts the domain’s DNS from its name servers (DnsProvider; *.ns.cloudflare.com is Cloudflare, known registrars are named, the rest after their name server’s domain) and links the provider’s CNAME help where we know it. When the DNS is on Cloudflare and our OAuth client is configured, the page offers Connect with Cloudflare:
- The owner presses the button (
POST settings/domain/cloudflare, on the shop’s host). The app stores a state and a PKCE verifier in the session and sends the browser tohttps://dash.cloudflare.com/oauth2/auth. - On Cloudflare’s consent screen the owner picks the account that holds the domain and approves the scopes.
- Cloudflare returns to
https://flowery.com.ua/oauth/cloudflare/callback(the root domain: the session cookie covers it, ADR-006). The app exchanges the code for a short-lived token, finds the owner’s zone (the domain, then each parent down to two labels), adds a DNS-onlyCNAME→connect.flowery.com.uafor each address of the pair — replacing anyA/AAAA/CNAMEalready on that exact name and saying so in the message — checks the pair at once, revokes the token and returns to the page.
Nothing of the owner’s is stored. Failures the owner can act on get their own sentence (consent cancelled, the authorized account has no zone for the domain, an expired attempt); everything else is one sentence, with Cloudflare’s own words in the log.
Our OAuth client at Cloudflare (one-time, by the owner):
- Cloudflare dashboard → the account → Manage Account → OAuth clients → Create client: name
Flowery, response typecode, grant typeauthorization_code, token authenticationclient_secret_post, redirect URLhttps://flowery.com.ua/oauth/cloudflare/callback, logo (the SVG fromlanding/), client URLhttps://flowery.com.ua. Scopes: Zone Read (zone.read) and DNS Write (dns.write), both required; nothing else (the full list isGET https://api.cloudflare.com/client/v4/oauth/scopeswith any token). Copy the client id and secret once. - Change Visibility → public (permanent; until then only members of our own Cloudflare account can authorize — enough for the first test with a domain of ours). Public needs the client URL’s domain verified: a
TXTrecord onflowery.com.ua(DNS only) with the value Cloudflare shows (cloudflare_oauth_client_publisher=…); Cloudflare polls it for up to two days, then the consent screen shows the verified (blue) shield instead of the amber one. - Forge →
flowery.com.ua→ Environment:CLOUDFLARE_OAUTH_CLIENT_ID,CLOUDFLARE_OAUTH_CLIENT_SECRET,CLOUDFLARE_OAUTH_SCOPES="zone.read dns.write". The button stays hidden until all three are set.
Domain Connect: one click at every provider that supports it
Section titled “Domain Connect: one click at every provider that supports it”Domain Connect is the standard behind Resend’s “Sign in to Cloudflare” button. What exists (12 October 2026):
- Template
flowery.com.ua.storefront.jsoningithub.com/Domain-Connect/Templates(PR #2263): oneCNAMEon the given host →connect.flowery.com.ua,hostRequired: true. The standard forbids aCNAMEon a bare apex, so Domain Connect setswwwor a subdomain, never the bare domain (that stays with Cloudflare’s own flow or a manual record, as before). - Signing key. Private half:
DOMAIN_CONNECT_PRIVATE_KEYin the Forge environment (base64 of the PEM; keep a copy in the password manager — nothing on the server disk, so a server move copies the environment and nothing else). Public half:TXT _dcpubkeyv1.flowery.com.ua, two parts (p=1,a=RS256,d=…). To rotate: publish the new key under_dcpubkeyv2, setDOMAIN_CONNECT_KEY_ID=_dcpubkeyv2and the new private key, then remove the old records. - Cloudflare onboarding. Cloudflare exposes a provider’s template only after an email to
[email protected](template link, the key’s name, SVG logo, proxy default DNS only, our account id for restricted testing first). GoDaddy, IONOS and the others read the repository themselves.
How the flow works in the app (DomainConnect, DomainConnectController):
- For each address of the pair that is not live, the zone is found as the longest suffix with a
TXT _domainconnect.<zone>record; that record names the provider, whose/v2/<zone>/settingsgivesproviderName,urlSyncUXandurlAPI; the provider must answer 200 for our template. Cached ten minutes. A bare domain is never offered (no host part). - The page shows “Підключити через {provider}” (when the DNS is on Cloudflare and our OAuth client is configured, the Cloudflare button is shown instead: it can replace a wrong record, Domain Connect cannot).
POST settings/domain/domain-connectstores a state in the session and sends the browser to{urlSyncUX}/v2/domainTemplates/providers/flowery.com.ua/services/storefront/apply?domain=…&host=…&redirect_uri=…&state=…&key=_dcpubkeyv1&sig=…— the signature is RS256 over the query string as sent (withoutkeyandsig).- The provider sends the browser to
https://flowery.com.ua/domain-connect/callbackwith the state (anderror/error_descriptionon cancel or failure). The app checks the address against Cloudflare at once and returns to the page; the scheduler keeps polling as always.
Removing a domain by hand
Section titled “Removing a domain by hand”Delete the custom hostname at Cloudflare (SSL/TLS → Custom Hostnames, or DELETE /zones/{zone}/custom_hostnames/{id}), then the workspace_domains row. The subdomain answers again at once; the old domain stops working when its CNAME is removed or Cloudflare drops the hostname.
What it has already found
Section titled “What it has already found”First real connections, 11–12 October 2026 (UTC), on batata with names under kolovsky.dev, whose DNS is on Cloudflare.
| Run | What happened | Timings |
|---|---|---|
shop.kolovsky.dev (11 Oct, 23:09) |
Typed the name; “Connect with Cloudflare” with the OAuth client still private (the owner’s own account); consent screen showed exactly Zone Read + DNS Write; two DNS-only CNAMEs created, tagged “Flowery storefront” |
www twin live after 90 s; the bare name after 6 min (certificate); redirects correct: www → bare, batata.flowery.com.ua → bare |
ion.kolovsky.dev (11 Oct, 23:39) |
After the OAuth client went public with its domain verified (blue shield); subdomain, so no twin was offered | Live after about 5 min; the owner’s email arrived with the check that first saw it live |
What it taught, each fixed the same day:
- The
wwwtwin was registered unasked, which producedwww.shop.kolovsky.devfor a subdomain. Now a twin is offered only for a bare domain or awww.name, as a checkbox, and can be added or removed later (#81). - After “Remove domain” the page’s buttons stayed disabled until a reload:
router.deletewas called with Inertia’spostsignature, so the “finished” callback never ran (#82). - Two things that look like faults but are not: the page shows “Waiting for DNS” until the next poll or “Check now” even when Cloudflare already serves the site; and the storefront answers “shop not found” for up to 30 seconds after a hostname goes live (the runtime caches that answer). Both resolve on their own.
- The owner’s zone keeps the
CNAMEs after a domain is removed; they are the owner’s records. The two from the first run were deleted by hand through the Cloudflare API.