Cloudflare Pages — Production Cutover Playbook

Last updated: 2026-04-10 Proven on: selfinquire.com (WordPress/LiteSpeed → Astro/CF Pages, 2026-04-09) Scope: Cutting a custom domain from any origin (WordPress, cPanel, etc.) to a Cloudflare Pages static site. Assumes DNS is already on Cloudflare nameservers.


Prerequisites

1. Cloudflare Account & Token Setup

Identify which CF account owns the domain (we have 3 separate accounts):

AccountEmailInfisical SourceZones
Personaldmitri@mrsunshine.me/cf-personal/selfinquire.com, collab-culture.com, co-nexus.co, creators-hub.co, regenerositysociety.com, zasage.me
Solanasismr.sunshine@solanasis.com/shared/solanasis.com, mrsunshine.me
Matchkeyzadmin@matchkeyz.com/matchkeyz/matchkeyz.io

For a new domain on the Personal account:

python3 ~/_my/_solanasis/infisical/link_cf_account.py <folder-name>
# Creates Infisical folder, adds /shared/ + /cf-personal/ imports, verifies auth

Required token permissions (on the scoped API token):

  • Zone > Zone > Read
  • Zone > DNS > Read
  • Zone > DNS > Edit (Write)
  • Account > Cloudflare Pages > Read
  • Account > Cloudflare Pages > Edit

If the scoped token is missing DNS permissions, use the Global API Key to update it:

# Get current token details
curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/user/tokens/$TOKEN_ID"
 
# Get DNS permission group IDs
curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/user/tokens/permission_groups" \
  | python3 -c "import sys,json; [print(f'{pg[\"name\"]}: {pg[\"id\"]}') for pg in json.load(sys.stdin)['result'] if 'DNS' in pg['name']]"
 
# DNS Read: 82e64a83756745bbbb1c9c2701bf816b
# DNS Write: 4755a26eedb94da69e1066d98aa820be

2. GitHub Actions Secrets

Ensure these are set in the repo’s GitHub Actions secrets/vars:

NameTypeValue
CLOUDFLARE_API_TOKENSecretThe scoped API token
CLOUDFLARE_ACCOUNT_IDSecretThe CF account ID
SITE_URLVariablehttps://yourdomain.com
UMAMI_WEBSITE_IDVariableFrom Umami dashboard

3. Dual Deploy Workflow

Add production deploy step to .github/workflows/deploy.yml:

- name: Deploy to staging
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
  run: npx wrangler pages deploy dist/ --project-name=mysite-staging --branch="$DEPLOY_BRANCH"
 
- name: Deploy to production
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
  run: npx wrangler pages deploy dist/ --project-name=mysite --branch=main

Pre-Cutover Code Fixes

Apply these before touching DNS:

Umami Analytics

  • Add UMAMI_WEBSITE_ID to src/lib/constants.ts
  • Add conditional <script> tag to BaseLayout:
    {UMAMI_WEBSITE_ID && (
      <script defer src="https://cloud.umami.is/script.js" data-website-id={UMAMI_WEBSITE_ID}></script>
    )}
  • Pass UMAMI_WEBSITE_ID as env var in the build step

WordPress Redirect Map (public/_redirects)

  • Map old WordPress paths to new Astro paths (301 redirects)
  • Block WordPress infrastructure paths (404):
    /wp-admin/*       /  404
    /wp-content/*     /  404
    /wp-includes/*    /  404
    /wp-json/*        /  404
    /wp-login.php     /  404
    /xmlrpc.php       /  404
    
  • CF Pages free plan allows 100 static + 100 dynamic redirect rules

Sitemap Redirect

Astro’s @astrojs/sitemap generates sitemap-index.xml, but GSC and other tools expect sitemap.xml. Add to public/_redirects:

/sitemap.xml  /sitemap-index.xml  301

Security Headers (public/_headers)

Minimum production headers:

/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
  Content-Security-Policy: default-src 'self'; ...

Commit, Push, Verify Staging

All code fixes must be committed, pushed, and verified on staging BEFORE touching production DNS.


Pre-Flight Verification Suite

Run against the staging URL first, then against production after cutover.

Check Categories (12)

SITE="https://your-staging.pages.dev"  # Change to production URL after cutover
 
# 1. All pages return 200
for path in "/" "/about/" "/services/" "/contact/" "/privacy/"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SITE$path")
  echo "  $([[ $STATUS == 200 ]] && echo '[OK]' || echo '[FAIL]') $path$STATUS"
done
 
# 2. Redirects work (301)
for path in "/old-path/"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SITE$path")
  echo "  $([[ $STATUS == 301 ]] && echo '[OK]' || echo '[FAIL]') $path$STATUS"
done
 
# 3. WordPress paths blocked (404)
for path in "/wp-admin/" "/wp-login.php" "/xmlrpc.php"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SITE$path")
  echo "  $([[ $STATUS == 404 ]] && echo '[OK]' || echo '[FAIL]') $path$STATUS"
done
 
# 4. Sitemap chain
curl -s -o /dev/null -w "%{http_code}" "$SITE/sitemap.xml"       # → 301
curl -s -o /dev/null -w "%{http_code}" "$SITE/sitemap-index.xml" # → 200
curl -s -o /dev/null -w "%{http_code}" "$SITE/sitemap-0.xml"     # → 200
 
# 5. MX records (production only — hard gate)
curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=MX"
 
# 6. Security headers
curl -sI "$SITE" | grep -iE "x-frame|strict-transport|x-content-type|content-security|permissions-policy"
 
# 7. SSL
curl -sI "$SITE" | head -1  # → HTTP/2 200
 
# 8. Umami analytics
curl -s "$SITE" | grep -o 'cloud.umami.is/script.js'  # present
curl -s "$SITE" | grep -o 'data-website-id="[^"]*"'   # correct ID
 
# 9. No WordPress artifacts
curl -s "$SITE" | grep -oE 'wp-content|WordPress|LiteSpeed'  # → nothing
 
# 10. Custom 404
curl -s -o /dev/null -w "%{http_code}" "$SITE/nonexistent/"  # → 404
 
# 11. robots.txt
curl -s "$SITE/robots.txt" | grep -i sitemap
 
# 12. Calendly/CMS (site-specific)
curl -s "$SITE/contact/" | grep -q 'calendly.com'
curl -s -o /dev/null -w "%{http_code}" "$SITE/admin/"  # → 200

Hard gate: ALL checks must pass before proceeding to DNS cutover.


DNS Cutover Procedure

Step 1: Snapshot DNS (Rollback Data)

SNAPSHOT="/tmp/${DOMAIN}-dns-snapshot-$(date +%Y%m%d-%H%M%S).json"
curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?per_page=100" \
  | python3 -m json.tool > "$SNAPSHOT"

Step 2: Record MX Baseline (Hard Gate)

curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=MX"

Save the MX record count, priorities, and content. If MX records change or disappear at ANY point during the cutover, STOP and rollback.

Step 3: Create CF Pages Production Project

curl -s -X POST \
  -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCT/pages/projects" \
  -d '{"name":"mysite","production_branch":"main"}'

Step 4: Deploy to Production Project

npx wrangler pages deploy dist/ --project-name=mysite --branch=main

Step 5: Delete Old Origin A Record

# Identify the A record ID
curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$DOMAIN&type=A"
 
# Delete it
curl -s -X DELETE -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$A_RECORD_ID"

Step 6: Verify MX Survived (Hard Gate)

# Must match Step 2 baseline exactly
curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=MX"

Step 7: Create CNAME Records

# Apex → pages.dev (CF does CNAME flattening at the apex)
curl -s -X POST -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  -d '{"type":"CNAME","name":"yourdomain.com","content":"mysite.pages.dev","ttl":1,"proxied":true}'
 
# www → pages.dev (direct, not chaining through apex)
curl -s -X PATCH -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$WWW_RECORD_ID" \
  -d '{"type":"CNAME","name":"www.yourdomain.com","content":"mysite.pages.dev","ttl":1,"proxied":true}'

Step 8: Bind Custom Domains

# Bind apex
curl -s -X POST -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCT/pages/projects/mysite/domains" \
  -d '{"name":"yourdomain.com"}'
 
# Bind www
curl -s -X POST -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCT/pages/projects/mysite/domains" \
  -d '{"name":"www.yourdomain.com"}'

Step 9: Wait for Active Status

Poll until both domains show status=active:

curl -s -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCT/pages/projects/mysite/domains"

Expected: status=active, verification=active, validation=active on both.

Step 10: Final MX Hard Gate

Verify MX records one more time after everything is active.


Post-Cutover Verification

Run the full 12-category pre-flight suite (above) against the production URL. Additionally:

  • Confirm no x-turbo-charged-by: LiteSpeed header (WordPress gone)
  • Confirm server: cloudflare header
  • Check www serves content (not 522)
  • Verify GitHub Actions dual deploy succeeds (staging + production)

Post-Cutover Housekeeping

  • Google Search Console: Submit sitemap.xml and request indexing of key pages
  • Umami: Verify pageviews appear within 24 hours
  • Service inventory: Update solanasis-docs/operations/service-inventory.md
  • Monitor: Watch CF analytics for unexpected 404s over 7 days
  • WordPress decommission: After 1-2 weeks clean operation, cancel hosting

Rollback Procedure

Trigger: Critical failure after domain binding (site down, email broken). Recovery time: 5-15 minutes.

# 1. Remove custom domains from Pages project
curl -s -X DELETE -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCT/pages/projects/mysite/domains/yourdomain.com"
curl -s -X DELETE -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCT/pages/projects/mysite/domains/www.yourdomain.com"
 
# 2. Delete the CNAME record
curl -s -X DELETE -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$CNAME_RECORD_ID"
 
# 3. Restore the original A record (from snapshot)
curl -s -X POST -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  -d '{"type":"A","name":"yourdomain.com","content":"ORIGIN_IP","ttl":1,"proxied":true}'
 
# 4. Verify MX records survived the rollback
# 5. Verify original site is serving again

Gotchas & Lessons Learned

IssueSolution
CF Pages domain stuck on “pending”Remove the binding and re-add it. Triggers fresh CNAME verification. Happened when the binding was created before the CNAME existed.
Astro sitemap path@astrojs/sitemap generates sitemap-index.xml, not sitemap.xml. Add a redirect in _redirects. GSC and other tools expect /sitemap.xml.
CF token missing DNS permissionsUse the Global API Key to update the scoped token via PUT /user/tokens/{id}. DNS Read ID: 82e64a83756745bbbb1c9c2701bf816b, DNS Write ID: 4755a26eedb94da69e1066d98aa820be.
3 separate CF accountsEach has its own Global API Key. Use link_cf_account.py to set up Infisical cross-linking. Don’t assume one key works for all zones.
WordPress origin IP vs CF anycastThe IPs visible via DNS lookup (e.g., 104.21.x.x, 172.67.x.x) are CF anycast IPs, NOT the WordPress origin. Get the real origin IP from the CF API or hosting panel.
CNAME at apexCF supports CNAME flattening at the apex. Create a CNAME (not A) record pointing to project.pages.dev. Proxied (orange cloud).
www CNAMEPoint www directly to project.pages.dev, not to the apex. Avoids chain resolution issues.
522 during transitionExpected for 1-5 minutes while CF Pages verifies the domain and issues the SSL certificate. Don’t panic.
MX recordsDeleting an A record does NOT affect MX records. But always verify as a hard gate at every step.
GitHub Actions first deployIf the production Pages project doesn’t exist when the workflow runs, it fails with “Project not found (8000007)“. Create the project first, then push or re-run.
Umami on static sitesUMAMI_WEBSITE_ID must be a build-time env var (GitHub Actions vars), not a Wrangler secret (runtime-only, unavailable in static output).

SEO & LLM Optimization Checklist

Apply these to every new site before or shortly after cutover:

Technical SEO

  • JSON-LD structured data on every public page (ProfessionalService, Person, Review, BreadcrumbList, WebSite)
  • <title> and <meta name="description"> unique per page
  • Open Graph + Twitter Card meta tags
  • Canonical URLs
  • Sitemap with redirect (/sitemap.xml/sitemap-index.xml)
  • robots.txt with sitemap reference and AI crawler blocks (Cloudflare Managed Content)
  • Image width and height attributes (prevent CLS)
  • site.webmanifest for PWA signals

LLM Discoverability

  • llms.txt at site root — structured markdown with site description, pages, services, contact
  • llms-full.txt — extended version with complete content for RAG
  • Reference in robots.txt: llms.txt: https://yourdomain.com/llms.txt
  • JSON-LD structured data (also consumed by LLMs via search grounding)

Security Headers

  • HSTS (Strict-Transport-Security)
  • CSP (Content-Security-Policy) — whitelist only needed origins
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Permissions-Policy — disable unused APIs
  • /admin/* gets additional X-Robots-Tag: noindex, nofollow

  • Website setup playbook: playbooks/ai-native-website-setup-playbook.md
  • Quick reference: playbooks/website-setup-cheat-sheet.md
  • CF hardening: operations/cloudflare-hardening-cheatsheet.md
  • Deployment security: operations/new-service-deployment-checklist.md
  • Infisical helper: ~/_my/_solanasis/infisical/link_cf_account.py
  • CF account map (memory): ~/.claude/projects/-home-zasage--my/memory/reference_cf_account_map.md