Publish on your own domain
Your documentation answers at docs.yourcompany.com — your domain, your canonical URLs, your sitemap. Search engines credit you, not us.
This is the one page for custom domains. Two earlier pages covered the same ground and disagreed with each other about the record type, the proxy target and where the panel lives; this replaces both.
Before you start
A space that is published and public.
Access to your domain's DNS, or the ability to put one file on the site.
The console panel: open the space, go to the Settings fold, and find the Address group with its Custom domain panel. Over the API, every step below is a call under
/orgs/{orgId}/spaces/{spaceId}/domains.
Pick one of three shapes
Shape | Address | DNS you create | TLS |
|---|---|---|---|
A subdomain we own |
| none | ours, instantly |
A whole hostname of yours |
| one routing record | ours, issued automatically |
A path of a site you already run |
| none | yours — TLS terminates at your proxy |
The third shape is the one to choose if you want the links your documentation earns to build yourcompany.com itself. A subdomain is a separate site as far as a search engine's domain-level signals go, so docs.yourcompany.com builds docs.yourcompany.com.
A subdomain we own
Use Claim subdomain (POST …/domains/subdomain with {"subdomain": "acme"}). Because we own the parent domain it verifies instantly, there is no DNS step, and the certificate is ours. A handful of labels — www, api, app, admin, docs, status and similar — are reserved.
A whole hostname of yours
1. Add it
In Add domain, enter the hostname and leave basePath empty.
POST /orgs/{orgId}/spaces/{spaceId}/domains
{ "hostname": "docs.yourcompany.com" }2. Prove you own it — any one of three proofs
The panel shows all three. You need only one, and proving a name once also proves everything under it, so a second subdomain of a domain you have already verified is trusted automatically.
Proof | What to do | Field in the API response |
|---|---|---|
DNS TXT | Add a |
|
A file | Serve the shown text at |
|
A meta tag | Put the shown |
|
The file proof is the easy one when DNS is not yours to edit. The meta tag is there for the case where neither DNS nor /.well-known is yours — a domain fronted by someone else's service.
3. Create the routing record we show you
This is the record the earlier pages got wrong. The record type depends on whether the hostname is an apex:
Your hostname | Record | Points at |
|---|---|---|
|
| our edge host |
|
| our edge IP |
A subdomain gets a CNAME so our address can change without you re-pointing anything. An apex cannot hold a CNAME, so it gets an A record and you accept that an IP change means an edit. Do not copy a value out of a guide — read it from the panel, or from the API:
GET /orgs/{orgId}/spaces/{spaceId}/domains
# → domain.dnsTarget = { "type": "CNAME" | "A", "name": …, "value": … }dnsTarget is null only on a path-mounted domain, which needs no DNS record at all, and on a deployment that pins no edge address.
Counting the records: two for a whole hostname if you verify by TXT (the TXT proof and the routing record), one if you verify by file or meta tag.
4. Verify, then activate
These are two separate steps, and conflating them is how a cutover goes wrong.
POST /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/verify
{ "activate": false } # prove ownership, change nothing about traffic
POST /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/activate
# now we answer this addressverified means we have proof you own the name. active means we answer requests for it. Verifying with {"activate": false} lets you prepare everything — proof, routing record, a last look at the content — while your own application keeps serving the address. POST …/activate is the moment of cutover. POST …/verify with no body activates in the same call, which is what you want for a brand-new hostname that nothing is serving yet.
A verify that finds no proof answers 400 (request.malformed) and says what each method saw, in details:
{
"code": "request.malformed",
"message": "Not verified — no proof found for docs.yourcompany.com: TXT: …; file: …; meta tag: ….",
"details": {
"txt": [
"TXT: _forumulate.docs.yourcompany.com has 1 forumulate-verify value(s) but none is forumulate-verify=<token> (value mismatch — found \"forumulate-verify=old\")"
],
"file": [
"file: https://docs.yourcompany.com/.well-known/forumulate-verify/<token> answered 404"
],
"meta": [
"meta tag: https://docs.yourcompany.com/ answered 200 but did not contain the expected value"
]
}
}A success is always a 200 with verified: true, so the status code alone tells you which happened.
A certificate is issued for you through Let's Encrypt once the name is verified and your DNS points here. You never manage certificates for a whole-host domain.
If you run an ACME client of your own for one of your hostnames, the platform can answer its HTTP-01 challenge. Register the challenge with POST /orgs/{orgId}/spaces/{spaceId}/domains/acme-challenge and { "hostname", "token", "keyAuthorization" } (space.update; the hostname must be one of this organization's domains, or it is a 404). The certificate authority then fetches GET /.well-known/acme-challenge/{token} on that hostname, and the platform answers with the key authorization as plain text for the next hour, or a 404 for an unknown or expired token.
5. Ask the product whether it worked
GET /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/setupThis is the check to run instead of guessing. It answers with a mode, a boolean served, and a one-line message saying what to do next.
| What it means |
|---|---|
| Already reaching our edge. Nothing to do. |
| A whole host that must point at us. |
| Either verified but not activated (your own application still serves it — call |
| An activated subfolder that still does not reach us. |
| A subfolder whose ownership is not proven yet. |
setup also returns crawlerBlocked. The probe sends an AI crawler's User-Agent (GPTBot), so true means a CDN in front of your domain refuses AI crawlers — usually a "block AI bots" rule — and AI assistants cannot fetch your pages. It says nothing about search engines, which such rules normally leave alone.
A domain that publishes a site has the same check at GET /orgs/{orgId}/sites/{siteId}/domains/{domainId}/setup, with the same answer shape.
The www. twin
A domain is registered for one hostname. yourcompany.com/docs is routed to us; www.yourcompany.com/docs is not, and reaches whatever else answers that name — usually your own application, which has no such page. setup looks at the twin every time and reports it:
wwwNotCovered: true—www.<host>resolves but does not reach this address.www— what we found:{ hostname, resolves, covered, how, detail }, wherehowisredirect-by-us,registered,redirects-here(your own app already redirects it to this address),served-by-us,not-resolvingornot-covered.
Two ways to cover it: redirect www in your own application, or have our proxy do it —
PUT /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/www
{ "alsoRedirectWww": true }
# or, for a site's domain: PUT /orgs/{orgId}/sites/{siteId}/domains/{domainId}/wwwalsoRedirectWww is also accepted when you add the domain. With it on, our proxy answers www.<host> at the same path with a permanent redirect straight to https://<host>/… (one hop). Point the www name's DNS where the bare name points; for a whole-host domain we also request a certificate for it. It is refused (422) on a hostname that already starts with www..
A path of a site you already run
1. Add it with a basePath
POST /orgs/{orgId}/spaces/{spaceId}/domains
{ "hostname": "yourcompany.com", "basePath": "/docs" }One hostname can carry several mounts — documentation at /docs, a community at /community — and each answers only under its own path. Publishing several spaces on one address makes it a site, whose sitemap indexes each space inside it, so you submit a single address to Search Console.
2. Prove it
The same three proofs. Serving the file is usually easiest here: it is a site you already control.
3. Forward the path to origin.forumulate.com
No DNS record. One rule in your own reverse proxy, and it must pass the original Host header.
nginx:
location /docs/ {
proxy_pass https://origin.forumulate.com;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_ssl_server_name on;
}Caddy:
@forumulate path /docs /docs/*
reverse_proxy @forumulate https://origin.forumulate.com {
header_up Host {host}
}Cloudflare: an Origin Rule (or a Worker) that, for requests matching yourcompany.com/docs/*, sets the origin to origin.forumulate.com and preserves the Host header.
Point it at origin.forumulate.com, and at nothing else. Two other plausible targets both fail, each in a way that is hard to diagnose:
forumulate.comsits behind a CDN, and a CDN edge answers 403 to a hostname it does not serve. Your request never reaches us.the
CNAMEtarget from the whole-host flow serves no certificate for its own name. nginx does not verify upstream TLS, so it appears to work; Caddy and Cloudflare Full (strict) verify, and abort the handshake. That asymmetry is why one customer's path mount worked and another's did not for the same configuration.
Your proxy keeps its own certificate. We never need one for your domain in this shape.
4. Verify, then activate
Same two steps. Here the ordering matters even more: the path is already serving your own application, so verify with {"activate": false}, add the proxy rule, then activate.
Sharing a proxy with us
If your own application runs behind the same Traefik we do, the routers we write for your path win it by design, not by rule length: each carries priority: 1000, which outranks any router whose priority is left to Traefik's default (the length of its rule). You do not need to lower your own routers' priority to make room for us. To take a sub-path back on purpose — a legacy redirect under /docs, say — give your router an explicit priority above 1000.
Plain http:// for the path is answered by a permanent redirect to https:// from our side, so the path is HTTPS-only whatever your application does on port 80.
Nested mounts: under your own app, or under another mount
A mount does not have to sit beside the others. Two arrangements work, and customers run both in production:
Under a path your own application owns. Your app serves
/, and Forumulate serves/shop— a site, or one space. Your app keeps every path outside/shop; your proxy forwards only/shop/*to us.Under another of your mounts. A site at
shop.example.com/shopand a space atshop.example.com/shop/help. The help space answers its own address; the site answers everything else under/shop.
The longest prefix wins. For each request we look at every live mount of the hostname and take the longest basePath the path falls under, on a segment boundary: the path equals the mount, or starts with the mount followed by /. With /shop and /shop/help both live:
Request path | Answered by |
|---|---|
| the |
| the |
| the |
| not us: no mount covers it |
GET /public/domains/resolve?host=…&path=… gives the same answer as the reader does. Use it to check a path before you announce it.
Only live mounts take part, meaning verified and activated. That gives you a clean cut-over for a nested mount: until you activate /shop/help, the /shop mount keeps answering /shop/help/… exactly as it did before.
What this means in practice:
No second proof. A hostname your organization has already proved covers every path under it, so a mount added beneath a proved one is created verified. It is not created active. Its
setupreportspendinguntil youPOST …/activate.No second proxy rule. If your proxy already forwards
/shop/*toorigin.forumulate.com, it is already forwarding/shop/help/*. Once the nested mount is activated,setupreportsverified(served) without your proxy changing at all.Paths outside every mount are never ours. Forward only the mounts. A request that reaches us for a path no mount covers (
/aboutabove, if your proxy forwarded the whole host) is redirected (307) to the same path on our own address, not served.One owner per exact address. The same
hostname+basePathcannot be registered twice (409 domain.taken). Overlapping paths are allowed; that is what nesting is.POST …/domains/previewlists what the hostname already publishes inotherAddressesOnHost, so you can see the overlap before you create anything.A space nested inside its own site. If the
/shop/helpspace is also a member of the/shopsite, it keeps the site's header and footer at its own address. The site's header links to it at/shop/help, which is its address on the same host, not at/shop/<slug>.
What GET …/setup says for a nested mount, in order:
State of the nested mount |
|
|
|---|---|---|
Proved (or covered by a proved hostname), not activated |
|
|
Our edge answers the path as this mount |
|
|
Our edge answers the path as a PARENT mount ( |
|
|
Not proved yet, nothing of ours answering |
|
|
Activated in the last 15 minutes, edge not seen yet |
|
|
Activated more than 15 minutes ago, still not reaching us |
|
|
Our edge names the mount that answered each request in an x-forumulate-mount header (/shop/help, or / for a whole host), next to x-forumulate-edge. setup counts an answer only when it names this mount, so a live parent answering a nested path is reported as the parent answering — never as the nested address being live. curl -sI shows the header too, which is the quickest way to see which mount a path reaches.
path comes with a forward block holding the proxy rule. For a mount nested under one you already forward, path almost always means the outer rule does not match the inner path, for example a location = /shop that matches exactly instead of the prefix location /shop/. The setup of a site's domain (/sites/{siteId}/domains/{domainId}/setup) works the same way.
If you put Cloudflare in front of your domain
Both of these are rules you add once, and both have bitten customers.
Turn Email Obfuscation off for the mounted paths
Cloudflare's Email Obfuscation rewrites email addresses in the HTML and injects a decoder script from /cdn-cgi/scripts/. That script carries no CSP nonce, so the browser refuses to run it, and every address on the page renders as [email protected] with a dead link. Add a Configuration Rule that sets Email Obfuscation to Off for the paths we serve.
npx --yes @forumulate/cli doctor https://yourcompany.com/docs fails its html-untouched check when this is still on.
Cache rule: respect the origin's Cache-Control
We already decide, per request, whether a reader response may be shared. An anonymous GET of a public reader document comes back
Cache-Control: public, s-maxage=60, stale-while-revalidate=60, max-age=0, must-revalidate
Vary: Accept-Encodingand everything else — a request carrying a session cookie, a cf_share_* private-space cookie, a cflx_vis_* visitor-gate cookie, a Next RSC data request, or a ?share= / ?draft= / ?preview= parameter — comes back
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidateSo the correct Cloudflare Cache Rule is: for the paths we serve, mark them eligible for cache and set Edge TTL and Browser TTL to use the Cache-Control header from the origin.
Do not use "Cache Everything" with an Edge TTL override. An override ignores no-store, which means Cloudflare can store a signed-in author's render — staff bar, edit affordances and all — and serve it to the public, or cache a redirect to sign-in.
Do not raise the TTL either, however tempting 1 hour looks. The window is a guarantee, not a default. Cache-Control is set before the page knows what status it will render, so the same header goes onto a 404. With a longer window: request a URL once before the page exists and your CDN holds the 404 while the new page is invisible; delete a page and your CDN keeps serving the last 200 while the retired page stays publicly readable. Both were reproduced on a customer domain, where the origin answered 200 and the edge served HTTP 404, cf-cache-status: HIT, age: 375.
If you connect your Cloudflare token under Settings → Custom domains, we purge each page's addresses as it publishes or is deleted, and the transition is immediate rather than TTL-bound. One thing worth knowing if you ever script a purge yourself: purge by URL does not evict reader HTML. We measured it on our own zone, twice, with a token that holds Cache Purge — an entry at cf-cache-status: HIT, age: 13 purged by its exact URL answers "success": true and goes on serving at age: 13, while a /_next/static/* asset at the same host purges by URL instantly. The form that works is {"prefixes":["your.domain/the/path"]}, which clears the page (and anything under it) and leaves its siblings alone. That is what we send.
Without a token, or on a CDN we cannot call, the TTL itself is what bounds every transition — which is why it is short.
As belt and braces you can add a cache bypass on cookie presence for forumulate_session, cf_share_* and cflx_vis_*. It should never fire, because those requests already carry no-store.
Restrict who may read the address
A custom domain took a hostname and a base path and nothing else, so a space published on your own domain was published to the whole internet or not at all. Each domain now carries an IP allow-list: the networks that address may be read from.
PUT /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/ip-allow-list
{ "ipAllowList": ["203.0.113.0/24", "198.51.100.7", "2001:db8::/32"] }
# → { "ipAllowList": [ … ] } the list as it is now storedEach entry is an IP address or a CIDR block, v4 or v6. Up to 200 of them, 64 characters each. The reply, and ipAllowList on every row of GET …/domains, is the list as stored.
Four things worth knowing before you set one:
An empty list means anywhere, which is what every domain has until you say otherwise.
{"ipAllowList": []}is how you remove the restriction, not how you lock the door.It is the whole list. The
PUTreplaces what is stored, the same shape as the redirect table: read it, edit it, send it back. There is no per-entry call, because "delete the one entry that is wrong" would otherwise be two.An entry we cannot parse is refused —
422 validation.failed, with the offending entries inerror.details.ipAllowList. An unreadable rule can never match, so storing one would lock the world out silently, and the moment to say so is while you are looking at the form.It is per domain, not per space. The same space can answer openly on one address and only to your network on another, which is how you put an internal handbook on
handbook.yourcompany.comwhile the public docs stay public.
A refused request gets 403 and a one-line text/plain body, never a page and never a sign-in prompt: the reader is on the right address and is being told their network is not on the list. The response carries x-forumulate-gate: ip-allow-list so you can tell this refusal apart from a routing failure, and Cache-Control: private, no-store — a cached 403 on a shared edge would lock out every network behind it.
Which address we compare. The one your edge resolved, in this order: CF-Connecting-IP, then X-Real-IP, then the first entry of X-Forwarded-For — but only when the hop that delivered the request is one we trust: Cloudflare's published ranges, or a proxy an operator has named. Those three headers are written by whoever sends the request, so trusting them unconditionally would make this list advisory: anyone who knew our origin address could send CF-Connecting-IP: and a value you had allowed. When the delivering hop is not trusted, the address we compare is that hop itself — the connection, which a sender cannot choose — and the headers it sent about itself are ignored.
A request with nothing to judge is refused rather than admitted. This is the same definition of "who is asking" the API's rate limiter uses, so the two tiers never disagree.
The check runs in the edge middleware, before a page is looked up or a byte is rendered, and a change evicts the cached domain lookup immediately rather than when the TTL feels like it.
Restricting origin traffic to Cloudflare's ranges
If your domain is fronted by your own Cloudflare and what you want is "nothing reaches my documentation except through my edge", the allow-list is the control — but it needs one setting on the Cloudflare side first, because of the order above. Cloudflare faithfully forwards the reader's address in CF-Connecting-IP, so an allow-list of Cloudflare's own ranges would refuse every genuine reader: the address we compare would be the reader's, not Cloudflare's.
In Cloudflare, Rules → Settings → Managed Transforms, enable Remove visitor IP headers (Cloudflare's name for it, not ours). That strips
CF-Connecting-IP,True-Client-IPandX-Forwarded-Forfrom the request Cloudflare sends us, so the address we resolve is the Cloudflare edge that connected.Put Cloudflare's published ranges on the allow-list. They are served as plain text at
https://www.cloudflare.com/ips-v4andhttps://www.cloudflare.com/ips-v6:
RULES=$( { curl -s https://www.cloudflare.com/ips-v4; \
curl -s https://www.cloudflare.com/ips-v6; } \
| sed '/^$/d' | jq -R . | jq -s . )
curl -sS -A 'my-app/1.0' -X PUT -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
"https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/domains/$DOMAIN/ip-allow-list" \
-d "{\"ipAllowList\": $RULES}"A request that reaches us without going through your Cloudflare — anything aimed at our origin with your Host header on it — then arrives from an address that is not in the list and gets the 403.
Two costs, both real, and both the reason this is a separate section rather than the default advice:
You give up the reader's address on that domain. With the headers stripped we see Cloudflare, so you cannot also allow-list your office network on the same domain, and anything downstream that works per address — the reader API's per-address rate limit, the addresses in your own analytics — sees your edge instead.
Cloudflare's ranges change. They are published, not constant. Because the
PUTis the whole list, re-sending the two files on a schedule is the maintenance, and the day a new range appears without you is the day that slice of your readers gets a403.
If what you actually want is "only my staff may read this", skip all of that and put your own egress addresses on the list: the reader's address is exactly what the default order resolves, and nothing on the Cloudflare side needs changing.
A sign-in domain for your readers
Readers who sign in through Atlas get a session cookie. If that cookie is set on our domain, a browser may refuse to send it from your docs domain. A reader domain is a hostname of yours, such as readers.yourcompany.com, that serves reader sign-in so the cookie is first-party to your parent domain. It is one per organization, separate from the hostnames above, and needs the org.manage_settings capability. It only applies to organizations on Atlas; otherwise the calls answer 409 atlas.not_configured.
PUT /orgs/{orgId}/reader-domain
{ "host": "readers.yourcompany.com" }The host must be a hostname of at least two labels, 3 to 253 characters (anything else is a 422); it is stored lowercased. The response is { "domain": { ... } } with domainId, host, status, live, cnameTarget and cookieDomain. Publish a CNAME from host to cnameTarget, then ask us to re-check it:
POST /orgs/{orgId}/reader-domain/verifyverify is a 404 until a PUT has created the domain. When DNS points where it should, live turns true. GET /orgs/{orgId}/reader-domain returns the current domain or { "domain": null }, and DELETE on the same address unbinds it ({ "removed": true }).
Troubleshooting
403 from the edge, or an HTML error page instead of your docs. Your proxy is pointed at forumulate.com rather than origin.forumulate.com, or it is not passing the original Host. Check with the Host you expect us to see:
curl -sI https://origin.forumulate.com/docs/ -H 'Host: yourcompany.com'A 200 or a redirect means the mount is right and the problem is in your proxy. A 403 means the Host we received is not one we serve — the domain is not activated, or the hostname has a typo.
The address answers your own application, not us. The domain is verified but not activated. GET …/setup reports mode: "pending". Call POST …/activate.
TLS handshake failures from Caddy or Cloudflare Full (strict). You are proxying to a hostname that does not serve a certificate for its own name. Use origin.forumulate.com.
Verified, DNS pointed, still a 404 on every page. The space is private, or its pages are unpublished. A private space answers an anonymous request with nothing, which looks identical to a routing failure. Make the space public, or check that a page is published, before suspecting DNS.
It worked and then stopped. We re-check a verified name's DNS over time and record when it last pointed somewhere other than us. A domain is proved once and then trusted forever; DNS is not forever. The panel surfaces the drift.
Check it from the outside
npx --yes @forumulate/cli doctor https://docs.yourcompany.comThat checks, from outside your network, that an anonymous reader gets the page rather than a sign-in, that the canonical link names your host and not ours, that robots.txt allows the path and names a sitemap, that a crawler gets the same answer a reader does, that the document is cacheable, and that nothing in the path is rewriting your markup.
Then fetch https://docs.yourcompany.com/sitemap.xml: every URL in it is on your domain. View the source of any page — its <link rel="canonical"> names your address, not ours. That is the whole point: a canonical on our domain would ask Google to rank us for your content.
Two public lookups answer the same questions our own reader asks, with no token:
GET
/public/domains/resolve?host=docs.yourcompany.com&path=/docssays what an address serves. The longest registered path prefix underpathanswers:space(withorgId,spaceId,typeandbasePath) for a single space,site(with itsspaces) for a domain that carries a whole site, orlibraryfor a content library's hosted output. All three arenullwhen the address serves nothing of ours.GET
/public/domains/primary?org=<org>&space=<space>returns the canonical address for a space's copy on our domain: your verified custom domain or the site it is part of, as{ origin, basePath, mode }.nullmeans our address is its own canonical.organdspacetake a slug or an id; givelibraryinstead ofspacefor a content library's hosted output.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Domain
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List domains in a space |
| |
| Create a domain in a space |
| |
| Create the subdomain in a space |
| |
| Create the acme challenge in a space |
| |
| Verify the domain (spaces) |
| |
| Activate the domain (spaces) |
| |
| Preview the domain |
| |
| Delete the domain (spaces) |
| |
| Get the setup in a space |
| |
| Replace the www in a space |
| |
| Replace the IP allow list in a space |
| |
| Replace the redirect in a space |
|
Reader Domain
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the reader domain in an organization |
| |
| Replace the reader domain in an organization |
| |
| Delete the reader domain in an organization |
| |
| Verify the reader domain |
|