Duvlify
GitHubGet started
Building the siteDeploying
Building the site

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.

Terminal
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 reindex

deploy 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.retrieval set to 'lexical', the default, the upload step exits immediately and costs nothing.
  • With 'ai-search', the upload step requires CLOUDFLARE_ACCOUNT_ID and AI_SEARCH_TOKEN in 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. Run wrangler deploy alone 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.

Terminal
SITE_URL=https://docs.example.com npm run deploy

On 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 with trailingSlash: 'never' in astro.config.ts. Cloudflare’s default treats /page/ as canonical for a directory build, and it redirects /page to it with a 301. That would affect every internal link on the site, since Astro writes them all without a trailing slash.
  • run_worker_first lists 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 Link and X-Llms-Txt headers that announce the Markdown twin and llms.txt, so an agent issuing a HEAD request finds them without parsing HTML.
  • It answers Accept: text/markdown on an ordinary page URL with that page’s Markdown, plus a Vary: Accept header.
  • 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:

TypeScript
// src/docs.config.ts
export const basePath = '';        // docs.example.com/getting-started
export const basePath = '/docs';   // example.com/docs/getting-started

Everything 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.

  1. Leave basePath at ''.
  2. 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.
  3. Set SITE_URL=https://docs.example.com in 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 runsDNS on Cloudflare?What to do
Cloudflare Workers or PagesyesAdd the route. Two Workers, one zone; the more specific route wins.
Webflow, Framer, Squarespace, WordPress, anywhereyes, proxied (orange cloud)Add the route. The apex keeps resolving to that host; only /docs/* is intercepted.
Vercel, Netlify, Renderyes, proxiedAdd 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 CloudflarenoMove 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:

  1. Set basePath to '/docs' in src/docs.config.ts and rebuild.

  2. 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/*", "…"]
    }
  3. Set SITE_URL=https://example.com: the origin only, with no path. The prefix comes from basePath. 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.txt finds the marketing site’s file, or a 404. Point at it explicitly: add a Sitemap: line and a comment in the root robots.txt, and ideally add a <link rel="alternate" type="text/plain" href="/docs/llms.txt"> in the marketing site’s <head>.
  • llms.txt is 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 read example.com/robots.txt and nothing else. The build emits one at /docs/robots.txt, but no crawler will ever fetch it. Copy its directives, and the Sitemap: line pointing at /docs/sitemap.xml, into the marketing site’s own robots.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 the rel="api-catalog" link from the <head> rather than pointing it at a 404. Add a route for example.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. Set agents.webmcpBridge to 'on', and this repository registers the tools itself. See AGENTS.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 site in astro.config.ts (or SITE_URL) at the real origin. Otherwise every absolute URL in the build is wrong.
  • Add seo.verification values 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. See AGENTS.md. AI Crawl Control and bot analytics also exist only for a zone. On a workers.dev hostname, the Worker’s own log line for AI crawlers is all you get.

Use these docs with your AI tools

An AI agent can read this documentation directly. You do not need an account or an API key. Everything here is public and read-only.

Query these docs via MCP

Recommended

Add this server to Claude, Claude Code, Cursor, Mistral, or any tool that supports MCP. Your agent can then search Duvlify documentation and read it in full, instead of answering from memory.

https://duvlify.dev/mcp
  • searchFind the passages that answer a question.
  • fetchRead one page in full, as Markdown.
  • list_pagesSee every page in this documentation.

Query these docs over HTTP

The same tools also work as plain web requests. Use this for scripts, or for any tool that does not support MCP. There is one endpoint per tool. Arguments go in the query string, and the answer comes back as JSON.

https://duvlify.dev/api/docs/search?query=custom+domain

Read the OpenAPI description. It is built from the same definitions as the tools, so it always matches what the endpoints do.

Read these docs as Markdown

Add .md to any page URL to get its Markdown source. You can also send the headerAccept: text/markdown to the page URL itself.

To read the whole documentation in one file, open llms-full.txt. For a short index of every page, open llms.txt.