---
title: Deploy to Cloudflare Pages
description: Upload a Reed build to Cloudflare Pages, with _headers and _redirects at the root of the upload.
---

<Note>
This recipe is written from Cloudflare's Pages documentation and Reed's output layout. It
has not been run as a live deploy. After your first deploy, check two things: a `.md`
mirror comes back as `text/markdown`, and a nested page URL such as
`/docs/ship/deploy/` resolves. The commands are in the last section.
</Note>

Cloudflare Pages serves a directory of static files. It reads `_headers` and `_redirects`
from the root of that directory, and it serves `404.html` for unknown paths. A Reed build
already fits that shape. The only work is putting the files where Pages looks for them.

## Choose how the files reach Pages

Pages offers two ways to deploy.

**Direct Upload.** You build somewhere else and upload the finished directory with
Wrangler. This is the better fit for Reed. Your CI already has a Reed build that works
offline (see [Offline builds in CI](/docs/ship/ci-offline)), and Pages only receives the
output. Cloudflare notes that a Direct Upload project cannot be switched to Git
integration later.

**Git integration.** Pages clones your repository and runs a build command. You set the
**Build command** and **Build output directory** in the project settings. For Reed, the
command must make the `reed` CLI available in Pages' build image, run `reed prefetch`, then
run `reed build --out dist`. The output directory is `dist`, or `stage` on a site with
`base` (see below).

The rest of this page uses Direct Upload.

## 1. Build

For a site served at the root of its hostname, the build is the deploy directory:

```sh
reed build --out dist
```

`dist/` already has `_headers`, `404.html` and, if you declared redirects, `_redirects` at
its root. Nothing else to stage.

## 2. Stage the tree if you set `base`

A Pages project is served at the root of its hostname: `<project>.pages.dev` or a custom
domain you attach. With `base: "/docs"`, Reed prefixes every link with `/docs`, but the
files still sit at the root of `dist/`. Upload `dist/` as it is and `/docs/` maps to
nothing. The deploy succeeds and every page 404s.

Give the upload a real `docs/` directory:

```sh
set -euo pipefail

reed build --out dist

rm -rf stage
mkdir -p stage/docs
cp -R dist/. stage/docs/

# Pages reads _headers and _redirects only at the root of the uploaded 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

# A top-level 404.html also keeps Pages out of single-page-app mode.
cp stage/docs/404.html stage/404.html
```

The resulting tree:

<Files>
  <Folder name="stage" defaultOpen>
    <File name="_headers" comment="rules match served paths" />
    <File name="_redirects" comment="only with redirects, see below" />
    <File name="404.html" comment="copy of docs/404.html" />
    <Folder name="docs" defaultOpen>
      <File name="index.html" />
      <File name="404.html" />
      <File name="llms.txt" />
      <Folder name="_astro" />
    </Folder>
  </Folder>
</Files>

Why the extra `404.html`: Pages treats a project with no top-level `404.html` as a
single-page application and answers unknown paths from the root. The copy at
`stage/404.html` turns that off. Unknown paths under `/docs/` still get
`docs/404.html`, because Pages looks for the nearest `404.html` up the directory tree.
Reed's 404 page references its assets with absolute `/docs/...` paths, so the root copy
renders correctly too.

If you have a site at a path on a domain that something else already serves, for example
`example.com/docs` next to a marketing site, a Pages project alone does not give you that.
[Cloudflare Workers](/docs/ship/hosts/cloudflare-workers) routes a single path to a
static-assets Worker, and it uses the same `stage/` layout.

## 3. `_headers` and `_redirects` stay at the root

Reed writes this `_headers` file into every build:

```txt
/*.md
  Content-Type: text/markdown; charset=utf-8

/llms.txt
  Content-Type: text/plain; charset=utf-8

/*/llms.txt
  Content-Type: text/plain; charset=utf-8
```

Pages reads it from the root of the uploaded directory and does not serve it as a file.
The rules match served URL paths. A `*` splat matches across `/`, so `/*.md` covers
`/docs/ship/deploy.md`, and `/*/llms.txt` covers `/docs/llms.txt`.

That is why the staging script moves the file up out of `stage/docs/`, and `_redirects`
with it. Left inside the mount directory, neither is read. Details on what each rule protects are on
[Response headers](/docs/ship/headers).

## 4. Deploy

Create the project once, then deploy the directory:

```sh
npx wrangler pages project create
npx wrangler pages deploy stage --project-name=my-docs
```

For a site without `base`, deploy `dist` instead of `stage`. To deploy a preview, add
`--branch=<name>`. Pages serves each branch at `<branch>.<project>.pages.dev`.

## Real redirects from `docs.json`

When `docs.json` declares `redirects`, `reed build` writes a `_redirects` file with one
line per entry, `base` already applied to both columns:

```txt
/docs/old-guide /docs/guides/getting-started 301
```

Pages applies a matching `_redirects` rule before it serves a file at the same path, so
the rule wins over the meta-refresh page Reed also writes at the old path. Readers and
crawlers get a real 301, or a 302 for an entry with `permanent: false`. See
[Redirects](/docs/site/redirects) for exactly what is emitted.

Keep the file at the root of the upload, as the staging script above does. For a site
without `base`, it is already at the root of `dist/`.

### Rules Reed does not write

Some rules are not page moves, such as sending the bare hostname to the docs mount. Add
those to the same file, after `reed build` and before you deploy, because the build
replaces its output directory:

```sh
printf '/ /docs/ 302\n' >> stage/_redirects
```

When you write a rule by hand:

- **Write the base yourself.** `_redirects` is read against served paths. `/old-guide`
  alone matches nothing on a based site.
- **State the status.** Pages defaults to 302 when you leave it out.
- **Order matters.** When two rules share a source, the first one applies, so Reed's
  lines come first.

## Verify after the first deploy

Check that a Markdown mirror has the right content type:

```sh
curl -s -o /dev/null -w "%{content_type}\n" https://my-docs.pages.dev/docs/ship/deploy.md
```

Expect `text/markdown; charset=utf-8`. If you get `application/octet-stream` or no
charset, `_headers` is not at the root of the upload.

Check that a nested page resolves:

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

Expect `200`. A 404 here, on a site with `base`, means you uploaded `dist/` instead of
`stage/`.

Then confirm an unknown URL returns a 404 status and Reed's page, and that a string from
this build is on the live site. [Deploy](/docs/ship/deploy) explains why the exit code is
not enough.

## Related

- [Netlify](/docs/ship/hosts/netlify) reads the same `_headers` and `_redirects` files,
  with different redirect rules.
- [Vercel](/docs/ship/hosts/vercel) needs the header rules rewritten as JSON.
- [site and base](/docs/site/addressing) for what `base` changes and what it does not.
- [llms.txt](/docs/derived/llms-txt) for what the plain-text content type protects.
