Your published site is a bundle of static files with a small Cloudflare Worker in front of it. The Worker handles the handful of paths that need code — forms, tracking, short links, file downloads, the MCP surface — and everything else streams straight out of the bundle without the Worker running at all. This page is the response contract that arrangement produces: the headers on every reply, the caching rules, the routing decisions, and, at the end, the honest list of what you cannot change.
The headers on every response
Two things set headers. Most come from a _headers file Leed generates into the build; two are stamped by the Worker at request time.
| Header | Value | Applies to | Set by | Configurable |
|---|---|---|---|---|
Content-Security-Policy | frame-ancestors 'none' | every path | _headers | No |
X-Content-Type-Options | nosniff | every path | _headers | No |
Referrer-Policy | strict-origin-when-cross-origin | every path | _headers | No |
Cache-Control | public, max-age=31536000, immutable | /static/*, live builds only | _headers | No |
X-Robots-Tag | noindex | /stubs/* always; /static/* on live builds; your *.workers.dev hostname; every URL of a preview site | _headers | No |
X-Site-Version | the commit the build came from | responses the Worker handles | the Worker | No |
X-Preview | true or false | responses the Worker handles | the Worker | No |
What the three site-wide ones buy you:
frame-ancestors 'none'means no other site can put your pages in an iframe. It is a clickjacking defense, and it applies to every embedder without exception — there is no allow-list to add a partner or an internal tool to.nosniffstops a browser from second-guessing a file’s declared content type. A text file that happens to start with<script>stays a text file.strict-origin-when-cross-originsends the full URL as the referrer within your own site, but only your origin to another site, and nothing at all when a reader follows a link from HTTPS to HTTP.
The generated file itself is short. This is the whole of it on a live build:
https://your-site.leed.workers.dev/*
X-Robots-Tag: noindex
/*
Content-Security-Policy: frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
/stubs/*
X-Robots-Tag: noindex
/static/*
Cache-Control: public, max-age=31536000, immutable
X-Robots-Tag: noindex/stubs/* is the URL space Leed uses for machine-facing stub pages; it is noindex on every build, live or preview.
X-Site-Version is the most useful of the two Worker stamps day to day: it is the commit SHA the live build was made from, so one request tells you whether a deployment actually reached your domain. Both stamps are applied as the Worker returns a response, so a request the Worker rejects before it gets that far — a refused cross-origin request, below — carries neither.
Static files are cached for a year
On a live build, everything under /static/* is served Cache-Control: public, max-age=31536000, immutable. That is a year, and immutable tells the browser not to revalidate even on a reload. It is what makes a repeat visit to your site instant.
Leed’s own generated assets are safe under that rule because their URLs carry a content hash in the query string. The stylesheet is requested as /static/css/tailwind.css?v=f3c5f298…, and the search index files the same way. Change the content, the hash changes, the URL changes, and the browser fetches the new file.
That trap and its workarounds are covered in more depth alongside the rest of static files and caching in your repository.
The one-year rule is emitted on live builds only. A preview site serves /static/* with no cache header at all, which is why preview reloads feel slower than the real thing.
URL shape and the 404 page
The static-asset binding runs with two settings that decide the shape of every URL on your site.
html_handling: "force-trailing-slash" means every page URL ends in /. A request for /pricing is answered with a 307 redirect to /pricing/ before anything else happens — worth knowing if you are writing redirects or link-checking, because the slash-less form is never the final URL.
not_found_handling: "404-page" means an unmatched URL is answered with your own built 404.html, at status 404. That page is src/404.hbs in your repository, with permalink: "404.html" and the reserved pageId "404". It uses your layout, your CSS and your navigation like any other page, and because of that reserved id it is excluded from the search index and from analytics session roll-up. Editing it is ordinary site repository work.
Old URLs are handled separately, by a generated _redirects file built from your page aliases and your page types’ redirect indexes — see aliases and redirects for how those entries are produced.
Which paths reach the Worker
Once your custom domain is active, the asset binding is given an explicit list of patterns to route through the Worker. Everything else is answered by the asset binding alone and never invokes it.
flowchart TD
R["Request to your domain"] --> W{"Path matches a Worker pattern?"}
W -->|"No — /static/**, images, fonts, any file URL"| A["Static asset binding"]
W -->|Yes| K{"Which prefix?"}
K -->|"/api/*, /f/*, /s/*, /e/*"| F["Proxied to Leed's customer functions"]
K -->|"/mcp, /mcp/*, OAuth discovery"| M["Docs MCP surface"]
K -->|"/.well-known/webmcp, /.well-known/agent.json"| G["Agent manifests, read from the bundle"]
K -->|"/ and any URL ending in /"| P["Page navigation, handled then passed on"]
P --> A
M -.->|"MCP off, or a preview site"| X["403 or 404"]
A --> S{"Is there a file for it?"}
S -->|Yes| OK["200, the built file"]
S -->|"No, and the URL has no trailing slash"| RD["307 to the slashed URL"]
S -->|No| NF["Your built 404.html, status 404"]
The consequence readers of this page usually care about: static subresources — CSS, JavaScript, images, fonts — never touch the Worker. You can see it in a response. A page request carries X-Site-Version and X-Preview; a request for /static/css/tailwind.css on the same site does not, because the Worker was never invoked to stamp them.
The exact pattern list, and why each entry is there
| Pattern | Handled by | Notes |
|---|---|---|
/ | the Worker, then the asset binding | Your home page. Present so page navigations run the Worker’s middleware. |
/**/ | the Worker, then the asset binding | Every other page URL — they all end in a slash. |
/api/* | proxied to Leed’s customer functions | Form submissions, tracking, recommendations, dynamic CTAs, live documentation search. |
/f/* | proxied | Gated file and document downloads. |
/s/* | proxied | Short links. |
/e/* | proxied | Email open, click and opt-out tracking links. |
/mcp and /mcp/* | the Worker, then proxied | The Docs MCP endpoint. |
/.well-known/* | the Worker | OAuth discovery for MCP, plus the two agent documents. Anything else under .well-known falls through to the bundle. |
The list has to be enumerated rather than inferred. With it in place, a path that is not on it is served by the asset binding alone — so a missing entry does not produce a helpful error, it silently serves the 404 page. That is also why the fall-through matters: a .well-known file you add yourself, such as security.txt, still works, because a .well-known path the Worker has no route for is handed back to the asset bundle.
The list is applied once your custom domain is active. Before that — and on preview sites — no pattern list is emitted, and anything with no matching file in the bundle reaches the Worker.
The /mcp and agent well-known routes are on the list unconditionally, but whether they answer with anything depends on the site: a site with MCP switched off gets a 403 at /mcp and a 404 at the two agent documents, decided at the edge before the request is proxied anywhere. Configuring the Docs MCP covers that switch.
The workers.dev address
Every site also has a *.workers.dev hostname. On the public site it stays switched on permanently, because the CMS’s page proxy fetches it directly to render previews. Once your custom domain is active, casual browser traffic on that hostname is 301ed to your domain, so a reader who finds the workers.dev URL ends up on your real one. Proxy traffic from the CMS is exempted so previews keep working.
The preview site’s workers.dev URL behaves differently: it is your only way in until the staging domain is verified, and it is switched off outright once that domain activates.
Both are marked X-Robots-Tag: noindex regardless, so neither can compete with your real domain in a search index. Until the custom domain is live, that workers.dev address is the site’s public face — domains and DNS covers getting off it.
Cross-origin requests
The Worker checks the Origin header on the requests it handles. A request whose origin is your own site (or its workers.dev hostname) proceeds and gets that origin echoed back in Access-Control-Allow-Origin. Anything else is refused with a 403 and a plain-text body:
Access Forbidden (CORS): 'https://example.com'That is the answer to “why can’t I fetch my own page from another site”. A request with no Origin header at all — a browser navigation, a crawler, curl — is not affected; the check applies only to genuine cross-origin calls.
Treat it as “your own site is allowed, everything else is refused” rather than as a configurable allow-list. There is no CMS control for it, and the comparison is a containment test against your site’s origins rather than an exact match.
Three surfaces deliberately answer everyone:
| Path | Allowed origins | Extra headers | Why |
|---|---|---|---|
Page URLs, /api/*, /f/*, /s/*, /e/* | your domain and its workers.dev hostname | — | These are called from your own pages, and some carry the reader’s cookies |
/mcp, /mcp/*, the OAuth discovery documents | * | Access-Control-Expose-Headers: WWW-Authenticate | MCP clients authenticate with bearer tokens and never send cookies, so an open policy is the correct one; the exposed header is how a client walks discovery from a 401 |
/.well-known/webmcp, /.well-known/agent.json | * | — | Read-only public metadata describing pages that are already public |
Files under /static/* are outside this entirely on a live custom-domain build: they are served by the asset binding, so the Worker’s CORS check never runs and they come back with no cross-origin headers of their own.
Spam checks on form submissions happen server-side, on Leed’s side of the proxy, using credentials the Worker forwards rather than anything embedded in your page — how forms work covers the submission path, and how Leed tracks visitors covers what else uses /api/*.
Preview and live differ in four responses
A preview site is the same build with different switches. The differences visible over HTTP:
| Behavior | Preview site | Live site |
|---|---|---|
X-Robots-Tag: noindex | Every URL, plus the workers.dev hostname | /stubs/*, /static/* and the workers.dev hostname |
/static/* caching | No cache header | public, max-age=31536000, immutable |
Tracking and form posts (/api/event, /api/clerk) | Accepted and discarded — 204, nothing recorded | Proxied and recorded |
/mcp* and the agent well-known documents | 404 | Served, when MCP is enabled for the site |
That is the HTTP-level view; preview site vs live site covers the rest of the differences, including which content each one contains.
One tier-sensitive edge behavior sits alongside these: below Starter, /api/search answers 404 rather than advertising a paid feature to a scanner. It is indistinguishable from “no such endpoint” on purpose, and live documentation search is where it is documented.
What you cannot configure
None of the above is a setting. _headers is generated from Leed’s template on every build and deleted from your repository at cleanup, so there is no supported way to:
- add a
Content-Security-Policyof your own, or extend the one Leed sets - set HSTS,
Permissions-Policy, or any other security header - change the cache lifetime on
/static/*, or add a per-path cache rule - widen or narrow the cross-origin policy
- add a route the Worker handles, or take one away
The supported escape hatch for per-page markup is header-includes.hbs, and it is worth being precise about what it does: it adds tags inside <head>, not HTTP headers. A <meta> tag, a verification token, a font preload, an analytics snippet — all fine, and it is the one hook that reaches your documentation pages as well as your marketing pages. See customizing the head. It cannot set a response header, and a <meta http-equiv> is not a substitute for one.
If a header you need is genuinely missing, that is a product gap rather than a configuration problem — the limits that are not plan limits and what Leed does not do pages are where those are tracked.