🪴 GoDeep
Search
← Shipping Software

Cloudflare Pages and static sites

What a static site actually is and why it costs almost nothing to serve, the difference between Git-built and `wrangler pages deploy` projects, how preview and production deployments work, custom domains, `_headers` and `_redirects`, SPA fallback routing, instant rollbacks, and why your site can look stale for minutes after a deploy.

12 cards · 6 quiz questions · 10 min read

You have 18 Pages projects. Some are wired to a GitHub repo and rebuild themselves when you push; some you deploy by running wrangler pages deploy from your laptop. Most were set up by an agent that picked one path without telling you why, and the differences only surface when something goes wrong: a deep link 404s, a deploy appears not to have landed, a redirect shadows the whole site. This is what those projects are actually doing.

What “static” really means

A static site is one where every URL corresponds to a file that exists before any visitor arrives. A build step turns your source into a folder — usually dist/ — of HTML, CSS, JavaScript and images, and serving a request is finding the right file and sending the bytes.

# What a build actually produces
$ npx astro build
$ find dist -type f | head -6
dist/index.html
dist/about/index.html
dist/_astro/index.Bd3f9a1.css    # content hash in the filename
dist/_astro/client.C7e21b0.js
dist/robots.txt
dist/favicon.svg

Nothing in that list runs. That is the whole economic argument: there is no process to keep warm, no database to query per request, no cold start. Requests for static assets on Pages are not billed as Worker invocations at all, which is why 18 sites can cost you nothing.

The trade is that anything genuinely dynamic — a login, a D1 query, a form submission — needs code, and on Pages that means a Pages Function, covered in the next topic.

Git builds versus direct uploads

Two kinds of Pages project exist, and the difference is where the build runs.

Git-connectedDirect Upload
Build runsIn a Cloudflare build containerOn your machine or your CI
TriggerA push to GitHubYou run wrangler pages deploy
Sees your env varsOnly the ones set in the dashboardWhatever your shell has
Build minutesCounted against your planNot counted
# Direct Upload: you build, then you push the finished folder
npx astro build
npx wrangler pages deploy ./dist \
  --project-name=policynews \
  --branch=main            # be explicit; see below

The important constraint is one an agent will never mention: the choice is permanent. Cloudflare’s docs are blunt about it — if you choose Direct Upload you cannot switch to Git integration later, you have to create a new project. So if one of your 18 sites is a direct-upload project and you now want push-to-deploy, plan on recreating the project and re-pointing the custom domain.

Direct Upload also has ceilings: 20,000 files with Wrangler, and 25 MiB for any single asset. A large PDF or MP4 in your output directory is the usual cause of a deploy that fails with no obvious code error.

Preview and production are decided by branch

Each project nominates one production branch. Commits to it update <project>.pages.dev and every custom domain attached to the project. Every other branch produces a preview deployment with two URLs: an immutable hash URL like 373f31e2.policynews.pages.dev that always points at exactly those files, and a branch alias like fix-api.policynews.pages.dev that moves with the branch.

Preview deployments carry an X-Robots-Tag: noindex header, so they do not compete with production in search results. They are otherwise public: anyone with the URL can read them unless you put a Cloudflare Access policy in front of preview deployments.

Here is the smell to watch for in a deploy script:

# Before — an agent's deploy script, no branch specified
npx wrangler pages deploy ./dist --project-name=citizentech

# After — say what you mean
npx wrangler pages deploy ./dist --project-name=citizentech --branch=preview-staging

Inside a Git working tree Wrangler reads your current branch and uses it. That is usually convenient, but it means the same command publishes to production or to a preview depending on where your HEAD happens to be, and outside a repo it can default to production. On an unattended machine or a cron job, always pass --branch.

The hash URL is the single most useful debugging tool Pages gives you. If the hash URL shows your new content and the custom domain does not, the deploy worked and the problem is caching — see below.

Custom domains and the DNS record underneath

Attaching news.example.com to a project creates a CNAME record from that hostname to <project>.pages.dev. For a subdomain you can host that CNAME anywhere. For an apex domain — example.com with no subdomain — the DNS standard forbids a CNAME at the zone root, so you need the zone on Cloudflare, which flattens the CNAME into A records at query time.

The order of operations matters. Add the domain in the Pages project first, then let Cloudflare create the record. Adding a CNAME by hand at your provider without first associating the domain with the project leaves the hostname failing to resolve, and the error message is not helpful.

_headers and _redirects

Both are plain text files you place in your build output directory, and both are read by Pages, not by your framework.

# dist/_headers
/*
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  !X-Powered-By                    # the ! prefix strips a header

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

Two hard facts. First, the limits: 100 header rules, and 2,000 characters per line. Second, and this one bites: custom headers from _headers are not applied to responses generated by Pages Functions, even when the URL pattern matches. If your /api/* routes are Functions, their security headers must be set in the Function’s own code. A Content-Security-Policy you believe is covering the whole site probably is not covering your API.

# dist/_redirects
/blog/*        /news/:splat        301   # splat -> :splat
/u/:id         /users/:id          301   # one segment only
/old-pricing   /pricing            301

The defaults trip people up. If you omit the status code you get a 302, a temporary redirect, not a 301 — fine for humans, wrong signal for search engines. Supported codes are 301, 302, 303, 307 and 308. * is greedy and referenced as :splat; :name matches a single path segment and stops at a /. The first matching line wins, so specific rules go above general ones. Limits are 2,000 static and 100 dynamic rules.

The genuinely dangerous line is a catch-all rewrite:

# Before — an agent "fixing" SPA routing. This shadows your entire site.
/*  /index.html  200

# After — delete it. Pages already does SPA fallback (see below).

Redirects are evaluated before Functions and take priority over static assets, so that one line intercepts everything.

SPA fallback hinges on one file

If your build output has no top-level 404.html, Pages assumes you have deployed a single-page application and matches all unmatched incoming paths to the root /, so your client-side router receives /dashboard/reports and renders it.

If a top-level 404.html does exist, that behaviour switches off: Pages looks for the closest 404.html walking up the directory tree and serves it. For a React SPA, adding a pretty 404 page is therefore the way to break every deep link in one commit. For a multi-page Astro site, a 404.html is exactly what you want.

Rollbacks

Open the project’s Deployments list, use the three-dot menu on an earlier deployment, and choose Rollback. Production switches instantly, because nothing is rebuilt — the old files were never deleted. Only successful production deployments are valid targets; preview deployments are not. You can roll forward again afterwards.

What a rollback cannot do is reverse anything outside the asset bundle. If the deploy also ran a D1 migration, the old front end is now talking to the new schema. That is the argument for shipping schema changes and front-end changes in separate deploys, with the schema change backwards-compatible.

Why the site looks stale right after a deploy

Pages’ own defaults are already tuned for this. Every asset you deploy stays cached on the CDN until your next deployment, and cacheable responses go out with Cache-Control: public, max-age=0, must-revalidate plus an ETag, so browsers revalidate and get the new bytes immediately.

Staleness after a deploy therefore almost always means caching you added. Cloudflare’s docs say it plainly: if you add caching to your custom domain, it may lead to stale assets being served after a deployment — and the same rules can cause issues with Pages redirects or Functions, because the cached response gets served before Pages can act on the request.

So the diagnostic sequence is:

  1. Load the deployment’s hash URL. New content? The deploy is fine.
  2. Check Cache Rules and any legacy Page Rules on that zone for an Edge TTL override on the hostname.
  3. If you need a fix now, purge the cache for the zone under Caching then Configuration.
  4. Remove the rule, or narrow it to hashed assets only.

That last point is the one legitimate case for custom caching: files with a content hash in the name, like /_astro/index.Bd3f9a1.js. The name changes when the bytes change, so a one-year immutable TTL is safe. Never apply it to HTML.

What to check in your own code

  • For each project, note whether it is Git-built or Direct Upload. Remember you cannot convert one into the other.
  • grep -R "pages deploy" . in every repo and check each command has an explicit --branch.
  • Look for a top-level 404.html in your build output. Is the project an SPA or a multi-page site? Does the presence or absence of that file match?
  • Open dist/_redirects and look for a /* ... 200 catch-all. If it exists, try removing it and testing deep links.
  • If any project has Functions, check whether its security headers live only in _headers. If so, they are missing from every API response.
  • In each zone, list Cache Rules and Page Rules touching a Pages hostname. Anything with an Edge TTL is your staleness suspect.
  • Search your output directory for files over 25 MiB: find dist -type f -size +25M.

Sources

  • Cloudflare Docs — Serving Pages website Route matching, the 404.html rule that switches on SPA fallback, the default Cache-Control header, and the warning that custom caching on your domain causes stale assets after a deploy.
  • Cloudflare Docs — Redirects website `_redirects` syntax, the 302 default, splats and placeholders, and the 2,000 static / 100 dynamic rule limits.
  • Cloudflare Docs — Headers website `_headers` syntax, the 100-rule limit, and the fact that these headers are not applied to responses generated by Pages Functions.
  • Cloudflare Docs — Direct Upload website `wrangler pages deploy`, the `--branch` flag, the 20,000-file / 25 MiB limits, and the one-way nature of choosing Direct Upload over Git.
  • Cloudflare Docs — Rollbacks website Instant rollback to any successful production deployment; preview deployments are not valid rollback targets.