Deploying
The build is static output. Deploy it to Cloudflare for every feature, or to any static host, with subdomain and subpath options and their costs.
The build is static. npm run build writes dist/, and dist/ is a complete
site: HTML, fingerprinted assets, Markdown twins, share cards, feeds, the
search index, and the agent manifest. Everything else in the repository stays
behind.
npm run build # astro check && astro build
npm test # builds, then asserts the outputs and the agent surfaces
npm run deploy # build + index:sync + wrangler deploy
npm run deploy:fast # build + wrangler deploy, no reindexdeploy runs the semantic index upload between the build and the deploy. This
way, a content change cannot ship with a stale index behind it. Two
consequences follow:
- With
agents.retrievalset to'lexical', the default, the upload step exits immediately and costs nothing. - With
'ai-search', the upload step requiresCLOUDFLARE_ACCOUNT_IDandAI_SEARCH_TOKENin the environment, and fails the whole command without them. This is deliberate. Publishing code against an index that no longer matches it is worse than not publishing. Runwrangler deployalone if you genuinely need to ship code without touching the index.
Set SITE_URL in CI. Canonical links, the sitemap, llms.txt, the feeds, and
every URL in the agent manifest are absolute, and they must name the real
origin.
SITE_URL=https://docs.example.com npm run deployOn Cloudflare
Cloudflare is the intended host. It is the only host where every feature works without a substitution.
wrangler.jsonc declares a Worker in front of the static assets. Two settings
there matter most:
html_handling: "drop-trailing-slash"must agree withtrailingSlash: 'never'inastro.config.ts. Cloudflare’s default treats/page/as canonical for a directory build, and it redirects/pageto it with a 301. That would affect every internal link on the site, since Astro writes them all without a trailing slash.run_worker_firstlists the paths the Worker sees. Fingerprinted bundles, fonts, share cards, and icons are excluded, so the build serves most requests straight off the asset path, at no invocation cost.
What the Worker adds
The Worker renders nothing. Every byte still comes out of dist through the
ASSETS binding. It exists for the things a file on disk cannot do:
- It adds
LinkandX-Llms-Txtheaders that announce the Markdown twin andllms.txt, so an agent issuing aHEADrequest finds them without parsing HTML. - It answers
Accept: text/markdownon an ordinary page URL with that page’s Markdown, plus aVary: Acceptheader. - It adds
Link: rel="canonical"on every Markdown response, which lets search crawlers read the Markdown without treating it as a duplicate of the page. - It provides the agent surfaces. See
AGENTS.md.
You can remove the Worker. Delete main, assets.binding, and
run_worker_first from wrangler.jsonc, and the site serves as pure static
assets again. A human visitor sees no difference.
Which deploy to run
npm run deploy reindexes the whole corpus between the build and the upload.
This is the right default. It is the only step that keeps semantic search in
step with the pages. Skipping it silently would leave search answering from a
version that no longer exists.
It is also the slow half. The sync sends one request per page, sequentially and at a controlled pace, so a few hundred pages take minutes. It pays that cost whether or not any page changed.
npm run deploy:fast skips this step. Use it when the change cannot affect
the corpus: styling, client-side behaviour, Worker code, or configuration. Use
the full deploy whenever content changed. When in doubt, use the full deploy;
a stale index is harder to notice than a slow deploy.
Neither variant is needed while agents.retrieval is 'lexical'. The lexical
index is built into dist/ and ships with the assets, so index:sync detects
that and exits immediately.
Where the documentation lives under your domain
Two shapes, chosen with one setting:
// src/docs.config.ts
export const basePath = ''; // docs.example.com/getting-started
export const basePath = '/docs'; // example.com/docs/getting-startedEverything the build prints picks up that prefix: page links, canonical tags,
the sitemap, llms.txt, the agent manifest, the search index, and the MCP and
API endpoints. The Worker strips the prefix back off before looking anything
up. Nothing inside dist/ moves; only the URLs change. Set it, rebuild,
deploy.
Prefer the subdomain. docs.example.com is one hostname, one Worker, and
one DNS record, with nothing to coordinate with whoever owns the marketing
site. Choose the subpath only when you need documentation and marketing to
share a domain for SEO, or because a design decision requires it.
Subdomain: docs.example.com
This setup works whether or not the rest of the site is on Cloudflare, because the sites share nothing but the registrable domain.
- Leave
basePathat''. - Deploy, then attach the hostname as a Custom Domain on the Worker (Workers & Pages → your Worker → Settings → Domains & Routes). Cloudflare creates the DNS record and issues the certificate.
- Set
SITE_URL=https://docs.example.comin the deploy environment.
If the apex domain is not on Cloudflare, add docs as a CNAME at your DNS
provider, pointing at the Worker’s workers.dev hostname. Use a Route instead
of a Custom Domain in that case.
Subpath: example.com/docs/
The mechanism is the same in every case: a Worker Route on
example.com/docs/*. Cloudflare’s edge evaluates a route before the request
reaches the origin, so the documentation Worker answers those paths, and
everything else carries on to wherever the marketing site actually lives. The
marketing platform never learns that /docs exists.
The requirement is therefore narrow. It is worth stating precisely, because it is the thing people assume is impossible: the domain’s DNS has to be on Cloudflare and proxied. The marketing site itself does not.
| Where the marketing site runs | DNS on Cloudflare? | What to do |
|---|---|---|
| Cloudflare Workers or Pages | yes | Add the route. Two Workers, one zone; the more specific route wins. |
| Webflow, Framer, Squarespace, WordPress, anywhere | yes, proxied (orange cloud) | Add the route. The apex keeps resolving to that host; only /docs/* is intercepted. |
| Vercel, Netlify, Render | yes, proxied | Add the route. Or use the platform’s own rewrite (vercel.json rewrites, Netlify _redirects with 200) pointing at the Worker, which keeps DNS wherever it is. |
| anywhere, DNS not on Cloudflare | no | Move DNS to Cloudflare, or use a subdomain instead. There is no third option: without the proxy there is nothing at the edge to intercept the path. |
Setting it up:
-
Set
basePathto'/docs'insrc/docs.config.tsand rebuild. -
wrangler.jsonc→ add the route, and prefix the asset exclusions, which are matched against the incoming path:JSON "routes": [{ "pattern": "example.com/docs/*", "zone_name": "example.com" }], "assets": { "run_worker_first": ["/docs/*", "!/docs/_astro/*", "!/docs/og/*", "…"] } -
Set
SITE_URL=https://example.com: the origin only, with no path. The prefix comes frombasePath. Putting it in both gives you/docs/docs/.
About the route pattern: the wildcard is a suffix operator only, paths are case-sensitive, and the build matches the pattern against the whole URL, including the query string.
What moves with the prefix, and what does not
llms.txt and llms-full.txt are ordinary build outputs, so they move like
every page. Under basePath: '/docs' the build serves them at
example.com/docs/llms.txt and example.com/docs/llms-full.txt. Every URL
inside them also carries the prefix: the .md link for each page, and the
## Optional block pointing at the MCP server, the API, the sitemap, and the
feed. An agent that fetches one of these files gets a map whose links all
resolve.
This is the intended behaviour, and it is the correct one. The alternative
would be to hoist the corpus index to the origin root, where it would sit next
to a marketing site that neither owns it nor knows about it. Documentation
published under /docs is a section of someone else’s site, so its index
belongs inside that section.
The same is true of sitemap.xml, updates.xml, openapi.json, the search
index, and the .md twin of every page. robots.txt is the exception; the
next section explains why.
Plan for two consequences:
- Nothing at the origin root advertises the documentation. An agent that
guesses
example.com/llms.txtfinds the marketing site’s file, or a 404. Point at it explicitly: add aSitemap:line and a comment in the rootrobots.txt, and ideally add a<link rel="alternate" type="text/plain" href="/docs/llms.txt">in the marketing site’s<head>. llms.txtis a convention, not a standard. No specification requires it to live at the origin root, and nothing enforces one location. Serving it beside the content it indexes is the defensible choice.
What a subpath cannot have
Three things are defined relative to the origin, so they cannot move under a prefix. None of these is fatal, but each needs a decision.
robots.txt. Crawlers readexample.com/robots.txtand nothing else. The build emits one at/docs/robots.txt, but no crawler will ever fetch it. Copy its directives, and theSitemap:line pointing at/docs/sitemap.xml, into the marketing site’s ownrobots.txt./.well-known/mcp.json, the server-card path, and/.well-known/api-catalog. A client looks for these at the origin root, which is outside your route, so the build does not serve them there. The MCP endpoint and the APIs themselves still work fine; only the pre-flight discovery is lost, and the build omits therel="api-catalog"link from the<head>rather than pointing it at a 404. Add a route forexample.com/.well-known/*if you want these back.- Cloudflare’s hosted WebMCP bridge. The Agent Readiness pack discovers a
site’s MCP server at
<origin>/mcp, but yours is at<origin>/docs/mcp. Setagents.webmcpBridgeto'on', and this repository registers the tools itself. SeeAGENTS.md.
The failure mode to know about
basePath and Astro’s own base in astro.config.ts must read from the same
constant. They cover different halves: base prefixes the URLs Astro
generates for the bundle, the fonts, and imported images; basePath prefixes
everything this codebase builds itself.
If you set only one, you get a site that builds cleanly, passes type checks,
renders its text, and is broken. With base alone, every stylesheet and
script 404s while page links still point at the root. With basePath alone,
the failure runs the other way. npm test asserts that every asset a page
references exists in the build. This turns the mismatch into a failure you see,
rather than one your readers do.
On another host
dist/ is ordinary static output, so any static host will serve the site.
Three things need attention.
Trailing slashes. The build writes page/index.html and links to /page.
Configure the host not to redirect between the two, or switch
trailingSlash in astro.config.ts and rebuild. A host that 301s every
internal link is slow and looks broken to crawlers.
Headers. public/_headers uses Cloudflare and Netlify syntax. On another
host, translate it: set a year of Cache-Control: immutable for /_astro/*,
and set the security headers for everything else. The file also documents why
each rule exists.
Content types. The host must serve .md as text/markdown, not download
it as an attachment. Most hosts get this right; some send
application/octet-stream, which turns “view as Markdown” into a download
prompt.
Losing the Worker costs you the header-based discovery and the Accept
negotiation. The .md URLs keep working, so agents that follow the documented
convention are unaffected. The MCP server and the HTTP API are Worker features
and do not survive the move. Set agents.enabled: false to turn them off
cleanly, rather than advertising endpoints that 404.
After the first deploy
- Point
siteinastro.config.ts(orSITE_URL) at the real origin. Otherwise every absolute URL in the build is wrong. - Add
seo.verificationvalues once the property is registered in Google Search Console and Bing Webmaster Tools. - Submit
/sitemap.xml. - If the domain is a Cloudflare zone, add a WAF rate limiting rule over
/api/docs/*and/mcp. SeeAGENTS.md. AI Crawl Control and bot analytics also exist only for a zone. On aworkers.devhostname, the Worker’s own log line for AI crawlers is all you get.