---
title: Deploy to Cloudflare Workers
description: 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/`.

## How the Worker sees your files

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:

<Files>
  <Folder name="stage" defaultOpen>
    <File name="_headers" comment="hoisted from dist/, read at the root" />
    <File name="_redirects" comment="hoisted too, only with redirects" />
    <Folder name="docs" defaultOpen>
      <File name="index.html" />
      <File name="404.html" />
      <File name="llms.txt" />
      <Folder name="_astro" />
      <Folder name="quickstart" />
    </Folder>
  </Folder>
</Files>

The general rule is on [Deploy](/docs/ship/deploy). The full list of files a build writes is on
[Build output](/docs/ship/output).

## 1. Build and stage

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

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](/docs/ship/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](/docs/site/redirects).

## 3. Configure the Worker

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.

## 4. Deploy

```sh
npx wrangler deploy
```

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

## A preview environment

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.

## Verify with curl

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](/docs/ship/deploy) describes. Grep a live page for a string you just added.

## Related

- [Cloudflare Pages](/docs/ship/hosts/cloudflare-pages) if you would rather upload a
  directory than configure a Worker.
- [nginx](/docs/ship/hosts/nginx) for the same layout on a server you run.
- [llms.txt](/docs/derived/llms-txt) for what the `llms.txt` content type protects.
- [Offline builds in CI](/docs/ship/ci-offline) to build `dist/` in the pipeline that
  runs `wrangler deploy`.
