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):
| Account | Infisical Source | Zones | |
|---|---|---|---|
| Personal | dmitri@mrsunshine.me | /cf-personal/ | selfinquire.com, collab-culture.com, co-nexus.co, creators-hub.co, regenerositysociety.com, zasage.me |
| Solanasis | mr.sunshine@solanasis.com | /shared/ | solanasis.com, mrsunshine.me |
| Matchkeyz | admin@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 authRequired 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: 4755a26eedb94da69e1066d98aa820be2. GitHub Actions Secrets
Ensure these are set in the repo’s GitHub Actions secrets/vars:
| Name | Type | Value |
|---|---|---|
CLOUDFLARE_API_TOKEN | Secret | The scoped API token |
CLOUDFLARE_ACCOUNT_ID | Secret | The CF account ID |
SITE_URL | Variable | https://yourdomain.com |
UMAMI_WEBSITE_ID | Variable | From 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=mainPre-Cutover Code Fixes
Apply these before touching DNS:
Umami Analytics
- Add
UMAMI_WEBSITE_IDtosrc/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_IDas 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/" # → 200Hard 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=mainStep 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: LiteSpeedheader (WordPress gone) - Confirm
server: cloudflareheader - Check www serves content (not 522)
- Verify GitHub Actions dual deploy succeeds (staging + production)
Post-Cutover Housekeeping
- Google Search Console: Submit
sitemap.xmland 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 againGotchas & Lessons Learned
| Issue | Solution |
|---|---|
| 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 permissions | Use 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 accounts | Each 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 anycast | The 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 apex | CF supports CNAME flattening at the apex. Create a CNAME (not A) record pointing to project.pages.dev. Proxied (orange cloud). |
| www CNAME | Point www directly to project.pages.dev, not to the apex. Avoids chain resolution issues. |
| 522 during transition | Expected for 1-5 minutes while CF Pages verifies the domain and issues the SSL certificate. Don’t panic. |
| MX records | Deleting an A record does NOT affect MX records. But always verify as a hard gate at every step. |
| GitHub Actions first deploy | If 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 sites | UMAMI_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.txtwith sitemap reference and AI crawler blocks (Cloudflare Managed Content) - Image
widthandheightattributes (prevent CLS) -
site.webmanifestfor PWA signals
LLM Discoverability
-
llms.txtat 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 additionalX-Robots-Tag: noindex, nofollow
Related Docs
- 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