Documentation Site Domains
How the two documentation sites got their own hostnames, and how to redo it.
| Site | Source | Pages project | Hostname |
|---|---|---|---|
| Developer docs | doc/ | zudo-text-doc | doc.zudo-text.app |
| End-user manual (JA) | manual/ | zudo-text-manual | manual.zudo-text.app |
Done — both domains are live
Both Pages projects exist, both custom domains are attached and serving, andDeploy Doc Site / Deploy Manual Site are green on main. Nothing on this page is outstanding.
It is kept as the procedure of record: the reasoning in "Why" below still governs how any future site is added, and sections 1–4 are the steps to repeat if a domain is ever re-attached or a third site appears. The optional follow-ups at the end are genuinely still open.
How it is wired
Both sites build root-mounted (
base: "/") and carry asiteUrl, so<link rel=canonical>andog:urlpoint at their own hostnames..andgithub/ workflows/ deploy- doc. yml deploy-manual.ymlbuild and deploy each site on every push tomainthat touches it, and deploy a per-PR preview on pull requests (pr-N.zudo-text-doc.pages.devand its manual counterpart).The combined
zudo-text-previewartifact no longer contains/ordoc/ /, and those paths are not redirected anywhere. The app is pre-release with no external users, so there was no link to keep working (rootmanual/ CLAUDE.md, "Pre-Release: No Backward Compatibility"). Both paths now fall through to that artifact's SPA catch-all and render the mock app.The one in-app link (Settings → API tokens → the personal-access-token doc link) points at
https:./ / doc. zudo- text. app/ … Both sites build root-mounted (
base: "/") and carry asiteUrl, so<link rel=canonical>andog:urlalready point at the new hostnames..andgithub/ workflows/ deploy- doc. yml deploy-manual.ymlbuild and deploy each site on every push tomainthat touches it, and deploy a per-PR preview on pull requests.The combined
zudo-text-previewartifact no longer contains/ordoc/ /, and those paths are not redirected anywhere. The app is pre-release with no external users, so there is no link to keep working (rootmanual/ CLAUDE.md, "Pre-Release: No Backward Compatibility"). Both paths now fall through to that artifact's SPA catch-all and render the mock app.The one in-app link (Settings → API tokens → the personal-access-token doc link) already points at
https:./ / doc. zudo- text. app/ …
Why the domain attach is not automated
A [[routes]] entry or an API-driven domain attach requires the deploy token to hold zone-level permissions on zudo-text.app. The shared CLOUDFLARE_API_TOKEN is deliberately account-scoped only. A dashboard-attached custom domain persists across every ordinary deploy with that narrower token untouched — the same reasoning that governs editor.zudo-text.app and the apex landing page in the Custom Domain & Email Ops Runbook §2.
Why each site needs its own project
zfb bakes base into every emitted URL and offers no way to vary it per build: there is no --base flag, no environment variable, and zfb.config.ts is evaluated inside an embedded V8 where process is undefined. One content tree can therefore serve / or /, never both. Giving each site its own Pages project also restores the per-PR previews it used to get from the shared artifact.
1. Confirm the zone is active
Nothing below works until zudo-text.app resolves through Cloudflare. It should already — https: returns 200 today. Confirm:
curl -s -o /dev/null -w '%{http_code}\n' https://editor.zudo-text.app/200 means the zone is live and you can proceed. Anything else means stop and work through the Custom Domain & Email Ops Runbook §1 first.
2. Create the two Pages projects
Already done for both. One command each, run locally; this only registers the project and uploads nothing.
npx wrangler@4 pages project create zudo-text-doc --production-branch main
npx wrangler@4 pages project create zudo-text-manual --production-branch mainYou will be prompted to log in if wrangler has no session. Creating a project that already exists fails harmlessly — if you see "project already exists", that step was already done.
Note
The project names are not free-form. deploy-doc.yml and deploy-manual.ymlpass them to wrangler pages deploy --project-name=…, so a typo here surfaces as a failing deploy, not as a wrong URL.
3. Trigger the first deploy, then attach the domains
Already done for both. Order matters when repeating it: attach the custom domain after a successful first deploy, so the hostname has content to serve the moment DNS resolves.
3a. Deploy
Either push a commit touching doc/ or manual/, or dispatch the workflows by hand:
gh workflow run deploy-doc.yml --ref main
gh workflow run deploy-manual.yml --ref main
gh run watchEach run ends by publishing to https: and https: respectively. Open both and confirm the site renders with working CSS — a page that loads as unstyled HTML means the base path is wrong, and you should stop rather than attach a domain to it.
3b. Attach
For each project, in the Cloudflare dashboard:
Workers & Pages → select the project (
zudo-text-doc/zudo-text-manual).Open the Custom domains tab. This is a Pages-project tab, and is a different screen from the Settings → Domains & Routes page used for Workers — do not go looking for it there.
Set up a custom domain → enter
doc.zudo-text.app(respectivelymanual.zudo-text.app) → confirm.Cloudflare adds the CNAME itself because the zone is on the same account. Wait for the status to go Active. This is usually under a minute, but certificate issuance can take longer.
Do not add a [[routes]] block or a custom_domain key to any committed config as part of this — see "Why the domain attach is not automated" above.
4. Verify
Both answer 200 and serve their own site at the root — last confirmed serving 262 KB titled zudo-text and 148 KB titled zudo-text マニュアル respectively, so this checks content and not just a status code:
curl -s -o /dev/null -w 'doc %{http_code}\n' https://doc.zudo-text.app/
curl -s -o /dev/null -w 'manual %{http_code}\n' https://manual.zudo-text.app/Then spot-check that a deep link survives the move — this is the URL now embedded in the app's API-tokens settings pane:
curl -s -o /dev/null -w '%{http_code}\n' \
https://doc.zudo-text.app/docs/guide/personal-access-tokens/If any of these return 000 from your own machine while the sites are demonstrably up, read the last bullet of section 5 before investigating anything server-side.
5. What the attach looked like
Recorded from the real cutover, because the intermediate states are alarming if you have not seen them:
Before the attach:
dig +short doc.zudo-text.appreturned nothing at all, while the*.pages.devproject URL already served the site. No DNS record means the attach never happened — Cloudflare writes the CNAME immediately when the zone is on the same account, so this is not a propagation wait.Just after the attach: DNS resolved to the Cloudflare edge (
104.21.74.111/172.67.157.118, the same pair aseditor.zudo-text.app) but both hosts answered 522 for a few minutes. That is the edge accepting the request before the Pages binding finishes — it clears on its own; nothing to fix.A stale local resolver outlives the fix. macOS caches the earlier negative lookup, so
curlcan keep failing instantly (time_namelookup=0.000000s) long after the domain works from everywhere else.digbypasses that cache, andcurl --resolve <host>:443:104.21.74.111proves the origin independently.sudo dscacheutil -flushcacheclears it.
Optional follow-ups
Neither is required; both are one-line changes.
Sitemaps.
sitemapis off in both configs. Now that each site has a real canonical origin, enablingsitemap: truemakes thesiteUrldo more work — zudo-doc will also add aSitemap:line torobots.txt.Indexing of the developer docs.
doc.zudo-text.apppublishes the generatedclaude-*categories (this project's CLAUDE.md, commands and skills, ~19 skill pages). They were already public onpages.dev, but a branded domain makes them findable rather than merely reachable. If that is not wanted, either dropclaudeResourcesfromdoc/or add azfb. config. ts robots.txtdisallow for those paths.