← Writing

Engineering

Cloudflare Pages already keeps your preview deploys out of Google

I set up per-branch preview deployments on Cloudflare Pages and reached for an Astro noindex conditional to keep them out of search. I did not need it: Cloudflare already noindexes previews at the HTTP layer. A note on where infrastructure logic belongs.

I wanted to review every change to this site on a real URL before it went public. Not localhost. The actual rendered page, deployed, so I could catch the layout problems a dev server hides.

Cloudflare Pages gives you that: push a branch, get a preview deployment at its own URL. But those preview URLs are public, and my first thought was the obvious one. I don’t want half-finished drafts turning up in Google. So I reached for an Astro conditional to stamp noindex on preview builds.

Then I checked whether I actually needed it. I didn’t. Cloudflare already does it, one layer below my application, and the reason my clever conditional was pointless is the interesting part of this post.

The setup: two systems, one job each

  • Cloudflare Pages deploys. Production from main, a preview for the other branches it is configured to build. It deploys every branch that builds, good change or bad, because a preview is for looking at. It runs no checks beyond the build command.
  • GitHub Actions is the quality gate. It runs format, type-check, and build on every pull request, and deploys nothing. It is what blocks a bad merge, once its job is a required status check in a branch protection rule or ruleset.

Connect the repo, set the build command (pnpm build) and output directory (dist), and choose the production branch (main here, but that is a project setting, not a rule). By default Cloudflare deploys your non-production branches as previews, and a pull request opened from the repository gets a preview URL posted on it. You can scope this to all branches, none, or a chosen set. Each preview gets an immutable hash URL and a per-branch alias.

That is the whole deploy configuration. Now the part I got wrong.

The noindex I didn’t need to write

Here is the code I was about to add to my base layout:

---
const isPreview =
  process.env.CF_PAGES === '1' && process.env.CF_PAGES_BRANCH !== 'main';
---

<head>
  {isPreview && <meta name="robots" content="noindex" />}
</head>

It is not wrong. Cloudflare injects CF_PAGES=1, CF_PAGES_BRANCH, CF_PAGES_URL, and CF_PAGES_COMMIT_SHA into every Pages build, so at build time a static Astro site can tell it is a preview and emit the tag. The mechanism is real.

It is just unnecessary, because Cloudflare already handles this a layer down. From their docs:

By default, every preview deployment generated by Cloudflare Pages includes the X-Robots-Tag: noindex HTTP response header.

You can watch it happen. Curl a preview URL and the production URL and compare headers:

$ curl -sI https://<hash>.my-project.pages.dev | grep -i robots
x-robots-tag: noindex

$ curl -sI https://my-project.pages.dev | grep -i robots
# (nothing)

Preview deployments carry noindex. Production does not. I had not written a line of code, and the exact behavior I wanted was already there.

Why the header is the right place and my meta tag was the wrong one

There are two ways to tell a crawler to skip a page:

<meta name="robots" content="noindex" />
X-Robots-Tag: noindex

They mean the same thing to Google, but they live at different layers. The meta tag exists only inside HTML that you generated. The HTTP header applies to the response itself, every response, whatever your app rendered and whether or not it is even HTML.

That difference is the whole point. Cloudflare knows a deployment is a preview because it created the preview. It does not need my application to re-derive that fact from an environment variable and act on it. The system that owns the fact applies the correct HTTP semantics, and my app never has to care.

That is the lesson worth keeping: do not solve an infrastructure problem inside your application. My isPreview check was pulling a fact that belongs to the deploy platform up into my page templates. It would only have covered HTML rendered through that layout, not images, PDFs, or any page that skips it. Cloudflare already answered the question, once, at the edge, for every response.

A static _headers file can also set X-Robots-Tag by hostname, using placeholders: https://:project.pages.dev/* for the production pages.dev address and https://:version.:project.pages.dev/* for previews, if you ever need indexing rules the defaults do not cover. For keeping previews out of search, you do not need it.

The layers, in one picture

                    git push / PR
                          │
             ┌────────────┴────────────┐
             ▼                         ▼
       GitHub Actions            Cloudflare Pages
       (quality gate)               (deploy)
             │                    ┌────┴────┐
   format / check / build         ▼         ▼
             │                  main      branch
             ▼                    │         │
        pass → merge              ▼         ▼
                              production  preview
                                  │         │
                              indexable   X-Robots-Tag:
                                            noindex

Cloudflare deploys and decides indexing. GitHub Actions decides whether a commit is allowed to become production. Neither job belongs in my page templates.

The one thing Cloudflare doesn’t do for you

Cloudflare noindexes previews, but your production *.pages.dev stays indexable. If you also serve the site on a custom domain, my-project.pages.dev and mydomain.com are now the same content at two addresses, which is a duplicate-content problem. The fix is not a preview conditional. It is a canonical URL on every page pointing at your real domain (this site sets one in its base layout), which search engines treat as a strong hint, or, to settle it, a Bulk Redirect from the pages.dev hostname to your domain. The _redirects file cannot match on hostname, so it cannot do this one.

And noindex is not access control. Anyone with a preview URL can still open it. If a draft must stay private, put the previews behind Cloudflare Access.

The quality gate

The deploy side does none of the checking, so a separate workflow runs the same commands I run locally, and a red check blocks the merge once the job is marked as a required status check.

name: CI
on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm check-format # prettier --check .
      - run: pnpm check # astro check
      - run: pnpm build

pnpm/action-setup reads the pnpm version from the packageManager field in package.json; without that field, give it a version. --frozen-lockfile fails the install if the lockfile is out of date, so CI builds the exact tree I committed. The three checks are the same scripts in package.json, so pnpm check-format && pnpm check && pnpm build locally means green in CI. cancel-in-progress kills a superseded run, so a burst of commits only spends minutes on the last one.

What I actually learned

I came in ready to write application code to keep previews out of Google, and the useful outcome was not writing it. Cloudflare deploys previews and marks them noindex. GitHub Actions gates the merge. The site sets a canonical URL. Three layers, each owning one job, and none of them my page templates.

The best infrastructure code is often the code you were about to write and then didn’t have to.

Esc