Cloudflare Static Site
Use this to run static sites and SPAs on Cloudflare Pages with Cloudflare DNS. Treat every DNS and Pages change as production-risk work. Pair with target-aware-security for any public DNS/TLS change and disciplined release practice when the task includes committing, pushing, or releasing.
Account And Credentials
- Resolve the target account explicitly before any write:
wrangler whoami, or GET /accounts via the Cloudflare API, and confirm the account name and ID with the user whenever the token can reach more than one account.
- Keep the API token in
CLOUDFLARE_API_TOKEN (environment variable or a mode-600 env file). Never print, paste, echo, or commit the token.
- Use
wrangler for Pages and Workers operations; use plain curl against api.cloudflare.com for everything else, passing the token via an Authorization: Bearer header sourced from the environment.
- For multipart uploads (e.g. Worker script upload), a forced
Content-Type: application/json header breaks the request. Call curl directly with the correct content type and pass the token via a mode-600 curl --config file so it never lands in argv.
- Account-owned tokens do not cover every product API (Registrar, Page Rules, Turnstile, Zero Trust). Those stay manual in the dashboard.
When Pages Beats S3 + CloudFront
Prefer Pages for a static/SSG bundle when you want free bandwidth, free TLS, instant custom domains, and redirect rules without extra infrastructure. Keep S3+CloudFront when you need signed URLs, Lambda@Edge, or an existing origin you are not ready to move. Do not run both in front of each other; two CDNs stacked only adds cost and confusion.
Deploy pattern: wrangler pages deploy <dist-dir> --project-name=<project> --branch=main. In CI, pin the wrangler version and keep the build's public env vars in the committed pipeline file so a redeploy is reproducible; keep secrets in CI/CD variables.
DNS Migration From Route53 (email-safe)
Never move nameservers before the replacement zone is verified. Order:
- Inventory everything, all values.
aws route53 list-resource-record-sets and read every ResourceRecords entry. A query that prints only ResourceRecords[0] silently hides second MX hosts and extra TXT records (SPF, domain verification). Losing those breaks mail.
- Create the Cloudflare zone and replicate every record. Nothing changes yet; the zone is inert until delegation.
- Translate Route53 ALIAS records. ALIAS has no Cloudflare equivalent: use a CNAME (Cloudflare flattens at the apex). One CNAME replaces the A+AAAA ALIAS pair.
- Start every record DNS-only (grey cloud). That makes the migration behaviour-identical. Turn proxying on later, deliberately, per hostname.
- Carry ACM validation CNAMEs if any CloudFront/ACM cert remains in use, or the cert silently fails to renew about 60 days later.
- MX is never proxied. DKIM/
_domainkey CNAMEs must stay DNS-only.
- Parity gate before flipping.
dig each record against the old authoritative NS and against <assigned>.ns.cloudflare.com, and diff. Expect one benign difference: at a subdomain Cloudflare returns a real CNAME where Route53 ALIAS returned A records; resolve the CNAME target and confirm the final IPs match. Wait about 30s after creating records; Cloudflare edge propagation lags creation and produces false empties.
- Flip. If the registrar is Amazon:
aws route53domains update-domain-nameservers, then poll get-operation-detail for SUCCESSFUL. Registry propagation still takes up to the parent TTL (commonly 3600s), so the old NS keeps answering meanwhile. That is expected.
- Verify after delegation: every web host, plus
MX, SPF, DMARC, and at least one DKIM selector. Send or receive a real message before calling it done.
- Keep the Route53 zone until verification passes; it is the rollback.
Pages Custom Domains
- Add the domain to the Pages project, and never assume a DNS record suffices. A DNS record alone routes nothing and issues no certificate.
- Validation is HTTP-based.
Always Use HTTPS must be OFF while it validates, or the ACME challenge is 301'd to a hostname with no certificate yet, which is a deadlock. Turn it back ON after the domain reports active.
- Statuses:
initializing then pending then active. pending with 0 certificate packs on a freshly moved zone usually means Universal SSL has not issued yet; it often resolves on its own. deactivated means DNS no longer points at Pages.
- Canary on
www (or any non-apex host) before flipping an apex that is serving traffic.
- Zone-level Universal SSL (
/zones/{id}/ssl/certificate_packs) covers example.com + *.example.com and is separate from the per-domain Pages certificate.
Redirect Between Hosts
Cloudflare Single Redirects (http_request_dynamic_redirect ruleset) are on the free plan and replace an S3+CloudFront redirect bucket. The source hostname must be proxied (orange) for a redirect rule to fire; a DNS-only record never reaches the edge. If the hostname is currently a Pages custom domain, remove it from the Pages project first or the two will fight over the same host.
Traps That Cost Real Downtime
- Pages answers unknown paths with
200 + text/html (the SPA shell). If a site previously served a PWA at that hostname, the old service worker fetches /sw.js, receives HTML, treats it as an invalid update, and keeps serving the old app forever. A 404 would have unregistered it; 200-HTML does not. Fix by serving a real self-destructing worker at that path: a Worker bound to example.com/sw.js returning application/javascript that calls skipWaiting, clears caches, registration.unregister(), then navigates clients. Keep the route until stale workers are gone.
- A new service worker cannot activate while any tab of that origin is open. Verifying a migration means closing every tab rather than just reloading.
- Never serve
sw.js, index.html, or manifest.webmanifest as immutable. A blanket \.(js|css|...)$ → Cache-Control: immutable, max-age=31536000 rule pins the service worker and shell for a year, so deploys never reach returning visitors. Hashed assets may be immutable; the shell and worker must revalidate.
- Different origins have separate caches and service workers. To see a deployment as a new visitor would, load it on a hostname you have never opened; that is far more reliable than clearing caches.
- Cached
immutable assets are served without revalidation even after they 404 on the server. Proof: the same URL returns 200 with cache:'default' and 404 with cache:'no-store'.
- Vite chunks import each other circularly (
index-*.js and App-*.js reference each other). Renaming one side and deleting the old file 404s the module graph and the app renders blank. Choose all new names up front, rewrite references in both directions, and resolve every relative import against disk before publishing.
Verification Standard
Before calling a Cloudflare static-site task complete:
- Every hostname returns the expected status and the expected
<title>.
- The served asset graph resolves: shell, entry chunk, every imported chunk, all
200.
- SEO surface intact where applicable: canonical,
robots.txt, sitemap.xml, JSON-LD, OG tags.
- For a migrated domain: mail records verified and a real message delivered.
- For a PWA-to-Pages migration: confirm a previously-visited browser converges on the new app, and never rely on a fresh browser alone.
- Report exposure state, the hostnames checked, what changed, and the rollback path.
Cutover Sequence That Actually Worked
Migrating a live S3+CloudFront site to Pages without downtime:
- Replicate DNS into Cloudflare DNS-only, parity-gate it, flip nameservers, wait for the registry.
- Build at content parity with what is live. Stash unrelated WIP first; shipping a feature during a hosting move makes any difference unattributable. Verify the shell is byte-identical (
md5) to the current origin before cutting over.
- Create the Pages project, deploy, and verify on
<project>.pages.dev.
- Add custom domains,
always_use_https OFF, repoint DNS to the Pages target proxied.
- Canary a non-apex host first (
www), confirm it is genuinely Pages-served (server: cloudflare, no x-amz-*/via: CloudFront), then flip the apex.
- Purge the zone cache after every repoint. Cloudflare keeps serving cached origin responses, so headers will still look like the old origin and you will wrongly conclude the cutover failed. Check
age: and x-amz-*.
- Expect a brief 522 on the apex while the custom domain flips
pending to active. It self-resolves in a few minutes; do not panic-roll-back.
- Restore
always_use_https, re-verify, then tear down.
Teardown Order
Disable before deleting, and confirm nothing else references the resources first (InUseBy, origin lookups):
- CloudFront: set
Enabled: false, wait distribution-deployed, then delete with the current ETag.
- S3: versioned buckets need both
Versions and DeleteMarkers purged before delete-bucket.
- ACM: delete only once
InUseBy is empty.
- Keep the old Route53 hosted zone. It costs about $0.50/month and is the only rollback for the whole migration. Delete it last, days later, deliberately.
Verification Traps
git stash push -u sweeps unrelated edits along with WIP. Run git stash show --name-only before assuming committed work survived, and guard staged files before committing.
- A test that fails on a bad assumption is harness debt and produces no product signal. Asserting CSP coverage for every URL in a bundle flags vendored constants (unselected RPC aliases, wallet-adapter metadata) that are never fetched; assert only on configured endpoints.
1---2name: cloudflare-static-site3description: Plan, provision, deploy, migrate, or audit a static site on Cloudflare (Pages, DNS, Redirect Rules, Workers). Use when the agent is asked to host a static site or SPA on Cloudflare Pages, move a domain's DNS from Route53 to Cloudflare, set up a Pages custom domain, build redirects between hosts, retire an S3/CloudFront/ACM stack in favour of Pages, or debug a Cloudflare-hosted site that serves stale content. Includes account guardrails, an email-safe DNS migration gate, and the service-worker and cache traps specific to Pages.4---56# Cloudflare Static Site78Use this to run static sites and SPAs on Cloudflare Pages with Cloudflare DNS. Treat every DNS and Pages change as production-risk work. Pair with `target-aware-security` for any public DNS/TLS change and disciplined release practice when the task includes committing, pushing, or releasing.910## Account And Credentials1112- Resolve the target account explicitly before any write: `wrangler whoami`, or `GET /accounts` via the Cloudflare API, and confirm the account name and ID with the user whenever the token can reach more than one account.13- Keep the API token in `CLOUDFLARE_API_TOKEN` (environment variable or a mode-600 env file). Never print, paste, echo, or commit the token.14- Use `wrangler` for Pages and Workers operations; use plain `curl` against `api.cloudflare.com` for everything else, passing the token via an `Authorization: Bearer` header sourced from the environment.15- For multipart uploads (e.g. Worker script upload), a forced `Content-Type: application/json` header breaks the request. Call `curl` directly with the correct content type and pass the token via a mode-600 `curl --config` file so it never lands in argv.16- Account-owned tokens do not cover every product API (Registrar, Page Rules, Turnstile, Zero Trust). Those stay manual in the dashboard.1718## When Pages Beats S3 + CloudFront1920Prefer Pages for a static/SSG bundle when you want free bandwidth, free TLS, instant custom domains, and redirect rules without extra infrastructure. Keep S3+CloudFront when you need signed URLs, Lambda@Edge, or an existing origin you are not ready to move. Do not run both in front of each other; two CDNs stacked only adds cost and confusion.2122Deploy pattern: `wrangler pages deploy <dist-dir> --project-name=<project> --branch=main`. In CI, pin the wrangler version and keep the build's public env vars in the committed pipeline file so a redeploy is reproducible; keep secrets in CI/CD variables.2324## DNS Migration From Route53 (email-safe)2526Never move nameservers before the replacement zone is verified. Order:27281. **Inventory everything, all values.** `aws route53 list-resource-record-sets` and read every `ResourceRecords` entry. A query that prints only `ResourceRecords[0]` silently hides second MX hosts and extra TXT records (SPF, domain verification). Losing those breaks mail.292. **Create the Cloudflare zone** and replicate every record. Nothing changes yet; the zone is inert until delegation.303. **Translate Route53 ALIAS records.** ALIAS has no Cloudflare equivalent: use a CNAME (Cloudflare flattens at the apex). One CNAME replaces the A+AAAA ALIAS pair.314. **Start every record DNS-only (grey cloud).** That makes the migration behaviour-identical. Turn proxying on later, deliberately, per hostname.325. **Carry ACM validation CNAMEs** if any CloudFront/ACM cert remains in use, or the cert silently fails to renew about 60 days later.336. **MX is never proxied. DKIM/`_domainkey` CNAMEs must stay DNS-only.**347. **Parity gate before flipping.** `dig` each record against the old authoritative NS and against `<assigned>.ns.cloudflare.com`, and diff. Expect one benign difference: at a subdomain Cloudflare returns a real CNAME where Route53 ALIAS returned A records; resolve the CNAME target and confirm the final IPs match. Wait about 30s after creating records; Cloudflare edge propagation lags creation and produces false empties.358. **Flip.** If the registrar is Amazon: `aws route53domains update-domain-nameservers`, then poll `get-operation-detail` for `SUCCESSFUL`. Registry propagation still takes up to the parent TTL (commonly 3600s), so the old NS keeps answering meanwhile. That is expected.369. **Verify after delegation:** every web host, plus `MX`, `SPF`, `DMARC`, and at least one `DKIM` selector. Send or receive a real message before calling it done.3710. Keep the Route53 zone until verification passes; it is the rollback.3839## Pages Custom Domains4041- Add the domain to the **Pages project**, and never assume a DNS record suffices. A DNS record alone routes nothing and issues no certificate.42- Validation is HTTP-based. **`Always Use HTTPS` must be OFF while it validates**, or the ACME challenge is 301'd to a hostname with no certificate yet, which is a deadlock. Turn it back ON after the domain reports `active`.43- Statuses: `initializing` then `pending` then `active`. `pending` with **0 certificate packs** on a freshly moved zone usually means Universal SSL has not issued yet; it often resolves on its own. `deactivated` means DNS no longer points at Pages.44- Canary on `www` (or any non-apex host) before flipping an apex that is serving traffic.45- Zone-level Universal SSL (`/zones/{id}/ssl/certificate_packs`) covers `example.com` + `*.example.com` and is separate from the per-domain Pages certificate.4647## Redirect Between Hosts4849Cloudflare **Single Redirects** (`http_request_dynamic_redirect` ruleset) are on the free plan and replace an S3+CloudFront redirect bucket. The source hostname **must be proxied (orange)** for a redirect rule to fire; a DNS-only record never reaches the edge. If the hostname is currently a Pages custom domain, remove it from the Pages project first or the two will fight over the same host.5051## Traps That Cost Real Downtime5253- **Pages answers unknown paths with `200` + `text/html` (the SPA shell).** If a site previously served a PWA at that hostname, the old service worker fetches `/sw.js`, receives HTML, treats it as an invalid update, and **keeps serving the old app forever**. A `404` would have unregistered it; `200`-HTML does not. Fix by serving a real self-destructing worker at that path: a Worker bound to `example.com/sw.js` returning `application/javascript` that calls `skipWaiting`, clears caches, `registration.unregister()`, then navigates clients. Keep the route until stale workers are gone.54- **A new service worker cannot activate while any tab of that origin is open.** Verifying a migration means closing every tab rather than just reloading.55- **Never serve `sw.js`, `index.html`, or `manifest.webmanifest` as `immutable`.** A blanket `\.(js|css|...)$ → Cache-Control: immutable, max-age=31536000` rule pins the service worker and shell for a year, so deploys never reach returning visitors. Hashed assets may be immutable; the shell and worker must revalidate.56- **Different origins have separate caches and service workers.** To see a deployment as a new visitor would, load it on a hostname you have never opened; that is far more reliable than clearing caches.57- **Cached `immutable` assets are served without revalidation even after they 404 on the server.** Proof: the same URL returns `200` with `cache:'default'` and `404` with `cache:'no-store'`.58- **Vite chunks import each other circularly** (`index-*.js` and `App-*.js` reference each other). Renaming one side and deleting the old file 404s the module graph and the app renders blank. Choose all new names up front, rewrite references in both directions, and **resolve every relative import against disk before publishing**.5960## Verification Standard6162Before calling a Cloudflare static-site task complete:6364- Every hostname returns the expected status and the expected `<title>`.65- The served asset graph resolves: shell, entry chunk, every imported chunk, all `200`.66- SEO surface intact where applicable: canonical, `robots.txt`, `sitemap.xml`, JSON-LD, OG tags.67- For a migrated domain: mail records verified and a real message delivered.68- For a PWA-to-Pages migration: confirm a previously-visited browser converges on the new app, and never rely on a fresh browser alone.69- Report exposure state, the hostnames checked, what changed, and the rollback path.7071## Cutover Sequence That Actually Worked7273Migrating a live S3+CloudFront site to Pages without downtime:74751. Replicate DNS into Cloudflare **DNS-only**, parity-gate it, flip nameservers, wait for the registry.762. Build at **content parity** with what is live. Stash unrelated WIP first; shipping a feature during a hosting move makes any difference unattributable. Verify the shell is byte-identical (`md5`) to the current origin before cutting over.773. Create the Pages project, deploy, and verify on `<project>.pages.dev`.784. Add custom domains, `always_use_https` OFF, repoint DNS to the Pages target **proxied**.795. **Canary a non-apex host first** (`www`), confirm it is genuinely Pages-served (`server: cloudflare`, no `x-amz-*`/`via: CloudFront`), then flip the apex.806. **Purge the zone cache after every repoint.** Cloudflare keeps serving cached origin responses, so headers will still look like the old origin and you will wrongly conclude the cutover failed. Check `age:` and `x-amz-*`.817. Expect a brief **522 on the apex** while the custom domain flips `pending` to `active`. It self-resolves in a few minutes; do not panic-roll-back.828. Restore `always_use_https`, re-verify, then tear down.8384## Teardown Order8586Disable before deleting, and confirm nothing else references the resources first (`InUseBy`, origin lookups):87881. CloudFront: set `Enabled: false`, `wait distribution-deployed`, then delete with the current `ETag`.892. S3: versioned buckets need **both** `Versions` and `DeleteMarkers` purged before `delete-bucket`.903. ACM: delete only once `InUseBy` is empty.914. **Keep the old Route53 hosted zone.** It costs about $0.50/month and is the only rollback for the whole migration. Delete it last, days later, deliberately.9293## Verification Traps9495- `git stash push -u` sweeps unrelated edits along with WIP. Run `git stash show --name-only` before assuming committed work survived, and guard staged files before committing.96- A test that fails on a bad assumption is harness debt and produces no product signal. Asserting CSP coverage for every URL in a bundle flags vendored constants (unselected RPC aliases, wallet-adapter metadata) that are never fetched; assert only on **configured** endpoints.