Skip to content

Deploy to Cloudflare Workers

Serve a Reed build as Workers static assets, mounted at a subpath, with _headers and _redirects in the right place.

A Worker with static assets and no script is a file server at the edge. You point it at a directory, and it serves that directory’s tree as URL paths. This is how this site is deployed at reedbuild.dev/docs, and every setting below comes from that deployment.

The recipe assumes base: "/docs". For a site at the root of its own hostname, skip the staging step and point the asset directory at dist/.

A static-assets Worker serves <directory>/a/b.html at /a/b.html. Nothing rewrites the path. routes decides which requests reach the Worker; it never adds or strips a path segment.

That matters because base changes the URLs inside the HTML, not where the files sit. With base: "/docs", dist/index.html links to /docs/_astro/..., but the file itself is at the root of dist/. Point the Worker at dist/ and /docs/ maps to nothing. The whole site 404s, and wrangler deploy still reports success.

The fix is to give the served tree a real docs/ directory:

  • Directorystage/!
    • _headers hoisted from dist/, read at the root
    • _redirects hoisted too, only with redirects
    • Directorydocs/!
      • index.html
      • 404.html
      • llms.txt
      • Directory_astro/
        • Directoryquickstart/

      The general rule is on Deploy. The full list of files a build writes is on Build output.

      Build to dist/, then copy its contents one level down into stage/docs/. Delete stage/ first, every time, so that a page you removed cannot survive as a stale asset:

      sh
      set -euo pipefail
      reed build --out dist
      rm -rf stage
      mkdir -p stage/docs
      cp -R dist/. stage/docs/
      # _headers and _redirects are read only at the root of the asset directory.
      mv stage/docs/_headers stage/_headers
      # _redirects exists only when docs.json declares redirects.
      if [ -f stage/docs/_redirects ]; then mv stage/docs/_redirects stage/_redirects; fi

      cp -R dist/. stage/docs/ copies the contents of dist/, not the directory itself. Without the trailing /. you get stage/docs/dist/ and the same 404s as before.

      Stop the script if dist/index.html is missing. A deploy of an empty or stale stage/ is the one way this setup ships nothing while reporting success.

      2. Hoist _headers and _redirects to the root

      Section titled “2. Hoist _headers and _redirects to the root”

      Reed writes a _headers file into every build. It tells the host to serve .md mirrors as text/markdown; charset=utf-8 and llms.txt as text/plain; charset=utf-8. Workers static assets read that file, but only at the top of the asset directory. Inside stage/docs/ it is ignored as a rule file and served as a plain file instead.

      So the staging script moves it to stage/_headers. The rules still match: they are written against served URL paths, and * is a splat that matches across segments, so /*.md covers /docs/ship/deploy.md. The /*/llms.txt rule is the one that matches /docs/llms.txt on a based site. The bare /llms.txt rule only helps a site at the root.

      Why the charset matters, and what the file contains, is on Response headers.

      When docs.json declares redirects, the build also writes _redirects, and the script moves it up for the same reason. Its lines already carry the base prefix, so they match served paths from the root. Workers static assets apply a matching _redirects rule before serving a file at the same path, so each moved page answers with a real 301, or a 302 for permanent: false, instead of Reed’s meta-refresh page. See Redirects.

      This is the wrangler.jsonc this site deploys with:

      jsonc
      {
      "$schema": "node_modules/wrangler/config-schema.json",
      "name": "reed-docs",
      "compatibility_date": "2026-07-01",
      "workers_dev": false,
      "assets": {
      "directory": "./stage",
      "html_handling": "auto-trailing-slash",
      "not_found_handling": "404-page"
      },
      "routes": [
      { "pattern": "reedbuild.dev/docs", "zone_name": "reedbuild.dev" },
      { "pattern": "reedbuild.dev/docs/*", "zone_name": "reedbuild.dev" }
      ],
      "env": {
      "preview": {
      "name": "reed-docs-preview",
      "workers_dev": true,
      "routes": []
      }
      }
      }

      Replace the name, domain and zone with your own. What each part does:

      • assets.directory is ./stage, not ./dist. This is the whole point of step 1.
      • html_handling: "auto-trailing-slash" serves docs/quickstart/index.html at /docs/quickstart/ and redirects /docs/quickstart to it. Reed emits its page links and canonical URLs with the trailing slash, so this matches the build. Footer links from docs.json are emitted as written, without an added slash, and reach their page through this redirect. It is also Cloudflare’s default; setting it explicitly keeps a future default change from moving your URLs.
      • not_found_handling: "404-page" serves the nearest 404.html up the directory tree, with a 404 status. Reed builds a real 404 page. Without this setting, an unknown URL gets a bare 404 with an empty body.
      • Two routes, not one wildcard. reedbuild.dev/docs* would also catch /documentation and /docsearch. The pair …/docs and …/docs/* catches the mount and nothing else. If another Worker already claims the whole hostname, these still win on their paths: Cloudflare picks the most specific matching route.
      • workers_dev: false keeps production off the workers.dev subdomain, so the site is reachable only at its real address.

      The config has no main script and no bindings. Nothing runs per request except asset serving.

      sh
      npx wrangler deploy

      Wrangler uploads stage/ and attaches the routes. There is no reed deploy: Reed’s job ended when it wrote dist/.

      The preview environment deploys the same assets as a separate Worker on workers.dev:

      sh
      npx wrangler deploy --env preview

      Two details make it safe:

      • It inherits assets. You cannot preview a different layout from the one production serves, because there is only one assets block.
      • routes: [] is explicit. routes is inheritable. Delete that line and the preview Worker inherits the production routes and takes over your live site.

      The preview serves at https://reed-docs-preview.<your-subdomain>.workers.dev/docs/. The /docs segment is still there, because the file tree is the same.

      A zero exit code from wrangler deploy tells you the upload finished, not that the site works. Check what the Worker actually serves.

      A nested page resolves, and the bare path redirects to the trailing-slash form:

      sh
      curl -s -o /dev/null -w "%{http_code}\n" https://reedbuild.dev/docs/ship/deploy/
      curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://reedbuild.dev/docs/ship/deploy

      Expect 200, then 307 pointing at the trailing-slash URL.

      The Markdown mirror and llms.txt carry the right content types:

      sh
      curl -s -o /dev/null -w "%{content_type}\n" https://reedbuild.dev/docs/ship/deploy.md
      curl -s -o /dev/null -w "%{content_type}\n" https://reedbuild.dev/docs/llms.txt

      Expect text/markdown; charset=utf-8 and text/plain; charset=utf-8. A missing charset means _headers is not being read. Check that it sits at stage/_headers, not inside stage/docs/.

      An unknown URL gets Reed’s 404 page, not an empty body:

      sh
      curl -s -o /dev/null -w "%{http_code} %{size_download}\n" https://reedbuild.dev/docs/no-such-page

      Expect 404 and a size in the thousands of bytes. A size of 0 means not_found_handling is not set.

      Finally, confirm you are looking at this build rather than a cached one, as Deploy describes. Grep a live page for a string you just added.

      • Cloudflare Pages if you would rather upload a directory than configure a Worker.
      • nginx for the same layout on a server you run.
      • llms.txt for what the llms.txt content type protects.
      • Offline builds in CI to build dist/ in the pipeline that runs wrangler deploy.