Skip to content

Deploy to Cloudflare Pages

Upload a Reed build to Cloudflare Pages, with _headers and _redirects at the root of the upload.

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.

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.

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), 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.

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.

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:

  • Directorystage/!
    • _headers rules match served paths
    • _redirects only with redirects, see below
    • 404.html copy of docs/404.html
    • Directorydocs/!
      • index.html
      • 404.html
      • llms.txt
      • Directory_astro/

    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 routes a single path to a static-assets Worker, and it uses the same stage/ layout.

    3. _headers and _redirects stay at the root

    Section titled “3. _headers and _redirects stay at the root”

    Reed writes this _headers file into every build:

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

    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.

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

    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.

    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 explains why the exit code is not enough.

    • Netlify reads the same _headers and _redirects files, with different redirect rules.
    • Vercel needs the header rules rewritten as JSON.
    • site and base for what base changes and what it does not.
    • llms.txt for what the plain-text content type protects.