In April 2025, Cloudflare deprecated Pages and told everyone to migrate to Workers. If you're here, you probably got that memo. What they didn't tell you is that migrating a production site with a custom domain is way more complicated than "just use Workers instead."
I just migrated my Gatsby blog from Pages to Workers. Here's everything that worked, everything that didn't, and the exact scripts that minimize downtime.
Why This Migration Matters
Cloudflare Pages is deprecated. New features go to Workers only. The Git-based auto-deploy that made Pages convenient? Gone. You deploy via Wrangler CLI now.
But here's the real challenge: moving your custom domain without breaking your live site.
The Cloudflare docs gloss over this part. They assume you're starting fresh, not migrating a production site serving actual traffic. This guide fills that gap.
What We're Migrating
Before:
- Deployment: Cloudflare Pages (auto-deploy from Git)
- Build: Gatsby static site
- Domain:
vibecodingwithfred.compointing to Pages deployment - Method: Push to GitHub → automatic build and deploy
After:
- Deployment: Cloudflare Workers (manual deploy via Wrangler)
- Build: Same Gatsby static site
- Domain:
vibecodingwithfred.compointing to Workers deployment - Method: Push to GitHub → GitHub Actions → Wrangler deploy
Prerequisites
Before starting:
- Cloudflare account with your domain
- Existing Pages deployment (the one you're migrating from)
- Node.js 18+ installed
- Cloudflare API token with "Edit Workers" permissions
- Git repo for your static site
Part 1: Setting Up Workers Configuration
Step 1: Install Wrangler
Add Wrangler to your project:
npm install -D wranglerStep 2: Create wrangler.toml
This is your Workers configuration. Create wrangler.toml at your project root:
name = "your-project-name"
compatibility_date = "2025-10-19"
main = "workers-site/index.js"
[site]
bucket = "./public"Key fields:
name: Your Workers project name (must match what's in Cloudflare dashboard)main: Entry point for your Worker scriptbucket: Where your static files are (Gatsby usespublic, Next.js usesout)
Step 3: Create the Workers Script
Workers needs a script to serve static files. Create workers-site/index.js:
import { getAssetFromKV } from '@cloudflare/kv-asset-handler';
const DEBUG = false;
addEventListener('fetch', event => {
try {
event.respondWith(handleEvent(event));
} catch (e) {
if (DEBUG) {
return event.respondWith(
new Response(e.message || e.toString(), {
status: 500,
}),
);
}
event.respondWith(new Response('Internal Error', { status: 500 }));
}
});
async function handleEvent(event) {
const url = new URL(event.request.url);
let options = {};
options.mapRequestToAsset = serveSinglePageApp;
try {
if (DEBUG) {
options.cacheControl = {
bypassCache: true,
};
}
const page = await getAssetFromKV(event, options);
const response = new Response(page.body, page);
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('Referrer-Policy', 'unsafe-url');
response.headers.set('Feature-Policy', 'none');
return response;
} catch (e) {
if (!DEBUG) {
try {
let notFoundResponse = await getAssetFromKV(event, {
mapRequestToAsset: req => new Request(`${new URL(req.url).origin}/404.html`, req),
});
return new Response(notFoundResponse.body, { ...notFoundResponse, status: 404 });
} catch (e) {}
}
return new Response(e.message || e.toString(), { status: 500 });
}
}
function serveSinglePageApp(request) {
const parsedUrl = new URL(request.url);
let pathname = parsedUrl.pathname;
if (pathname.endsWith('/')) {
pathname = pathname.concat('index.html');
} else if (pathname.includes('.')) {
// Serve files with extensions as-is
} else {
pathname = pathname.concat('.html');
}
parsedUrl.pathname = pathname;
return new Request(parsedUrl.toString(), request);
}This script:
- Uses
@cloudflare/kv-asset-handlerto serve static files - Handles Gatsby's client-side routing
- Serves 404.html for missing pages
- Adds security headers
Step 4: Install Dependencies
npm install @cloudflare/kv-asset-handlerStep 5: Update package.json Scripts
Change your deploy scripts from Pages to Workers:
{
"scripts": {
"build": "gatsby build",
"deploy": "npm run build && wrangler deploy",
"deploy:prod": "npm run build && wrangler deploy"
}
}Step 6: Test Local Deployment
Before touching your live site, test the Workers deployment:
npm run build
npx wrangler deployThis deploys to a preview URL like: your-project.your-subdomain.workers.dev
Visit that URL and verify everything works. Test navigation, images, API calls—everything.
Part 2: Setting Up GitHub Actions CI/CD
Pages auto-deployed from Git. Workers don't. You need GitHub Actions.
Create .github/workflows/deploy.yml:
name: Deploy to Cloudflare Workers
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Workers
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deployGet Your Cloudflare Credentials
Account ID:
- Cloudflare Dashboard → Workers & Pages
- Copy from the right sidebar
API Token:
- Cloudflare Dashboard → My Profile → API Tokens
- "Create Token" → Use "Edit Cloudflare Workers" template
- Copy the token (you only see it once!)
Add GitHub Secrets
Go to your repo → Settings → Secrets and variables → Actions → New repository secret
Add:
CLOUDFLARE_API_TOKEN: Your API tokenCLOUDFLARE_ACCOUNT_ID: Your account ID
Any other environment variables your build needs (like Supabase keys).
Test the GitHub Actions Deploy
Push to main and watch the Actions tab. The build should succeed and deploy to your preview URL.
Part 3: The Domain Switchover (The Hard Part)
This is where it gets tricky. Your domain is currently pointing to your old Pages deployment. You need to switch it to Workers without taking your site offline.
The Problem
You can't just:
- ❌ Manually edit DNS CNAME to point to
your-project.workers.dev(causes 522 errors) - ❌ Add domain in Workers dashboard (gives "domain already in use" error)
- ❌ Delete domain from Pages first (site goes down until you re-add it)
The Solution: Atomic API Switchover
Use the Cloudflare API to remove from Pages and add to Workers in rapid succession.
Script 1: Find Your Old Pages Project
Create scripts/1-find-old-project.sh:
#!/bin/bash
set -e
if [ -z "$1" ]; then
echo "Usage: ./1-find-old-project.sh YOUR_API_TOKEN"
exit 1
fi
CLOUDFLARE_API_TOKEN="$1"
CLOUDFLARE_ACCOUNT_ID="your-account-id-here"
echo "🔍 Finding all Pages projects and their domains..."
echo ""
curl -s -X GET "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" | jq -r '.result[] | "Project: \(.name)\nDomains: \(.domains // [] | join(", "))\n"'
echo ""
echo "📝 Look for the project that has your custom domain"
echo "📝 Copy that project name for the next script"Make it executable:
chmod +x scripts/*.shRun it:
./scripts/1-find-old-project.sh YOUR_API_TOKENFind the project name that has your domain. Example output:
Project: my-old-site
Domains: my-old-site.pages.dev, yourdomain.comScript 2: Atomic Domain Switchover
Here's the critical script. Create scripts/2-switch-domain.sh:
#!/bin/bash
set -e
if [ -z "$1" ] || [ -z "$2" ]; then
echo "Usage: ./2-switch-domain.sh YOUR_API_TOKEN OLD_PROJECT_NAME"
exit 1
fi
CLOUDFLARE_API_TOKEN="$1"
OLD_PROJECT_NAME="$2"
CLOUDFLARE_ACCOUNT_ID="your-account-id"
ZONE_ID="your-zone-id" # Get from DNS settings
DOMAIN="yourdomain.com"
NEW_WORKER_NAME="your-new-worker-name"
echo "🚀 ATOMIC DOMAIN SWITCHOVER"
echo "================================"
echo "Old Project: ${OLD_PROJECT_NAME}"
echo "New Worker: ${NEW_WORKER_NAME}"
echo "Domain: ${DOMAIN}"
echo ""
read -p "Press ENTER to proceed or Ctrl+C to cancel..."
# Step 1: Remove domain from old Pages project
echo ""
echo "⏱️ [1/3] Removing domain from old Pages project..."
curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${OLD_PROJECT_NAME}/domains/${DOMAIN}" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" > /dev/null
echo " ✅ Domain removed from Pages"
# Step 2: Add Workers route for root domain
echo "⏱️ [2/3] Adding Workers route for ${DOMAIN}..."
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/workers/routes" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"pattern\":\"${DOMAIN}/*\",\"script\":\"${NEW_WORKER_NAME}\"}" > /dev/null
echo " ✅ Route added for root domain"
# Step 3: Add Workers route for www subdomain
echo "⏱️ [3/3] Adding Workers route for www.${DOMAIN}..."
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/workers/routes" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"pattern\":\"www.${DOMAIN}/*\",\"script\":\"${NEW_WORKER_NAME}\"}" > /dev/null
echo " ✅ Route added for www subdomain"
echo ""
echo "🎉 SWITCHOVER COMPLETE!"
echo ""
echo "✅ Domain ${DOMAIN} is now pointing to ${NEW_WORKER_NAME}"
echo "✅ Test: curl -I https://${DOMAIN}"Run the Switchover
IMPORTANT: Make sure your new Workers deployment is working on the preview URL first!
Then run:
./scripts/2-switch-domain.sh YOUR_API_TOKEN old-project-nameDowntime: About 2-5 seconds between removing from Pages and adding to Workers.
Verify the Switch
# Check root domain
curl -I https://yourdomain.com
# Check www subdomain
curl -I https://www.yourdomain.com
# Check both work in browserPart 4: Post-Migration Cleanup
Update DNS (If Needed)
Your DNS CNAME record might still point to old-project.pages.dev. That's fine—Workers routes override DNS. But you can update it to be cleaner:
Option 1: Leave it as-is (works fine, Workers routes take precedence)
Option 2: Update CNAME to point to your Workers preview URL (optional, cosmetic)
Enable HTTPS Redirect
Make sure "Always Use HTTPS" is enabled:
- Cloudflare Dashboard → SSL/TLS → Edge Certificates
- Toggle "Always Use HTTPS" → ON
This ensures http://yourdomain.com redirects to https://yourdomain.com.
Delete Old Pages Project (Optional)
Once everything works, you can delete the old Pages deployment:
- Workers & Pages → old project name
- Settings → scroll to bottom
- "Delete Project"
This is optional—the old project isn't costing you anything if you want to keep it as a backup.
Test Your CI/CD Pipeline
Make a small change, push to main, and verify:
- GitHub Actions runs
- Build succeeds
- Deploys to Workers
- Live site updates in ~2 minutes
Common Issues and Solutions
Issue 1: "domain already in use"
Cause: Domain is still attached to old Pages project.
Fix: Run script 1 to find the old project, then script 2 to remove it.
Issue 2: 522 Connection Timeout
Cause: Tried to point CNAME directly to your-project.workers.dev.
Fix: Don't manually edit DNS. Use Workers routes via API (script 2).
Issue 3: www subdomain doesn't work
Cause: Workers route only added for root domain, not www.
Fix: Add second Workers route for www.yourdomain.com/* (script 2 handles this).
Issue 4: API token "Authentication error"
Cause: Token doesn't have "Edit Workers" permissions, or start date is in the future.
Fix: Create new token with:
- Template: "Edit Cloudflare Workers"
- Start date: TODAY (not blank, not future date)
- Expiration: 1 year from now
Issue 5: GitHub Actions fails with dependency conflicts
Cause: npm peer dependency conflicts (common with Gatsby plugins).
Fix: Add .npmrc to your repo:
legacy-peer-deps=trueAlso lock Node version with .nvmrc:
20Issue 6: Build works locally, fails in CI/CD
Cause: Missing environment variables or wrong Node version.
Fix:
- Add all required env vars as GitHub secrets
- Set Node version in GitHub Actions workflow
- Check build logs for specific errors
The Complete Migration Checklist
- Install Wrangler:
npm install -D wrangler - Create
wrangler.tomlwith correct project name - Create
workers-site/index.jswith static asset handler - Install
@cloudflare/kv-asset-handler - Update
package.jsonscripts to usewrangler deploy - Test local deployment:
npm run deploy - Verify preview URL works completely
- Create
.github/workflows/deploy.yml - Get Cloudflare API token and Account ID
- Add GitHub secrets
- Test GitHub Actions deployment
- Create migration scripts (1-find-old-project.sh, 2-switch-domain.sh)
- Find old Pages project name
- Run atomic domain switchover
- Verify both root and www domains work
- Enable "Always Use HTTPS"
- Test full CI/CD pipeline
- Delete old Pages project (optional)
What You Get After Migration
✅ Automatic deployments on push to main (via GitHub Actions)
✅ Full control over when and how you deploy
✅ Same performance as Pages (both use Cloudflare's edge network)
✅ Future-proof (Workers gets all new features)
✅ More powerful (can add serverless functions later)
The Bottom Line
Migrating from Pages to Workers isn't as simple as Cloudflare's deprecation notice implied. The domain switchover is the hardest part, and the docs don't cover it well.
But once you have the scripts and process down, it's straightforward:
- Set up Workers with wrangler.toml and the asset handler script
- Configure CI/CD with GitHub Actions
- Test everything on the preview URL
- Switch the domain atomically via API
- Verify and clean up
Total hands-on time: about 2 hours. Actual downtime: less than 5 seconds.
And now you're on the platform that Cloudflare is investing in. Plus you learned how to use the Cloudflare API for zero-downtime deployments, which is a useful skill for other migrations.
The scripts in this guide are production-tested. I literally used them to migrate this blog. They work.
Good luck with your migration. And if Cloudflare deprecates Workers in 2027, at least you'll know how to migrate again.
Resources:
- Cloudflare Workers Documentation
- Wrangler CLI Docs
- Workers KV Asset Handler
- GitHub Actions for Wrangler
Migration scripts shown in this article demonstrate the complete switchover process.
Appendix: Managing DNS via Cloudflare API
While Workers routes override DNS for domain routing, you might want to manage DNS records programmatically. Here's how.
List All DNS Records
ZONE_ID="your-zone-id"
API_TOKEN="your-api-token"
curl -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json" | jq '.result[] | {type: .type, name: .name, content: .content, proxied: .proxied}'Create a DNS Record
# Add CNAME record
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"type": "CNAME",
"name": "subdomain",
"content": "target.example.com",
"proxied": true,
"ttl": 1
}'Update a DNS Record
First, get the record ID:
RECORD_ID=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?type=CNAME&name=yourdomain.com" \
-H "Authorization: Bearer ${API_TOKEN}" | jq -r '.result[0].id')
echo "Record ID: ${RECORD_ID}"Then update it:
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"type": "CNAME",
"name": "yourdomain.com",
"content": "new-target.workers.dev",
"proxied": true,
"ttl": 1
}'Delete a DNS Record
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
-H "Authorization: Bearer ${API_TOKEN}"Get Your Zone ID
If you don't know your Zone ID:
# List all zones
curl -s -X GET "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer ${API_TOKEN}" | jq -r '.result[] | "\(.name): \(.id)"'
# Or find specific zone
curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=yourdomain.com" \
-H "Authorization: Bearer ${API_TOKEN}" | jq -r '.result[0].id'Complete DNS Migration Script
If you want to update DNS records as part of your migration:
#!/bin/bash
set -e
API_TOKEN="$1"
ZONE_ID="$2"
OLD_TARGET="old-site.pages.dev"
NEW_TARGET="new-site.workers.dev"
DOMAIN="yourdomain.com"
# Find the DNS record
echo "🔍 Finding DNS record for ${DOMAIN}..."
RECORD_ID=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?type=CNAME&name=${DOMAIN}" \
-H "Authorization: Bearer ${API_TOKEN}" | jq -r '.result[0].id')
if [ "$RECORD_ID" = "null" ]; then
echo "❌ DNS record not found"
exit 1
fi
echo "✅ Found record ID: ${RECORD_ID}"
# Update the DNS record
echo "📝 Updating DNS record..."
RESPONSE=$(curl -s -X PATCH \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"type\": \"CNAME\",
\"name\": \"${DOMAIN}\",
\"content\": \"${NEW_TARGET}\",
\"proxied\": true,
\"ttl\": 1
}")
if echo "$RESPONSE" | jq -e '.success' > /dev/null 2>&1; then
echo "✅ DNS record updated successfully"
echo ""
echo "Old: ${DOMAIN} → ${OLD_TARGET}"
echo "New: ${DOMAIN} → ${NEW_TARGET}"
else
echo "❌ Failed to update DNS record"
echo "$RESPONSE" | jq '.'
exit 1
fiImportant Notes on DNS Updates
Proxied vs DNS-only:
"proxied": true→ Orange cloud (CDN/DDoS protection)"proxied": false→ Grey cloud (DNS only)
For Workers routes, you want proxied: true.
TTL Settings:
"ttl": 1→ Automatic (recommended when proxied)"ttl": 300→ 5 minutes"ttl": 3600→ 1 hour
CNAME Flattening:
Cloudflare automatically handles CNAME flattening for root domains, so you can use CNAME records on yourdomain.com (normally not allowed in DNS).
Why Workers Routes > DNS Changes
For this migration, we used Workers routes instead of DNS changes because:
- Workers routes take precedence over DNS
- No propagation delay (instant edge updates)
- No CNAME target issues (can't CNAME to workers.dev directly)
- Better control over routing patterns
But if you're managing other DNS records (MX, TXT, A records), the API is useful for automation.
Conclusion
Migrating from Cloudflare Pages to Workers gives you more control over your deployment pipeline, faster builds (no waiting in Cloudflare's queue), and access to advanced Workers features like KV storage, D1 databases, and edge logic.
While the initial setup takes 20-30 minutes, the investment pays off with:
- Full control over deployment timing
- Local testing before pushing to production
- CI/CD flexibility with GitHub Actions
- Edge computing capabilities for dynamic content
- Zero downtime domain switchovers
The migration patterns shown here are production-tested—this blog migrated using these exact steps with zero downtime.
Related Cloudflare Guides
Master Cloudflare's ecosystem with these comprehensive guides:
- Deploy Your Static Site to Cloudflare Workers - Complete guide to Workers deployment from scratch
- Move Your DNS from GoDaddy to Cloudflare - Get your domain on Cloudflare for better performance
- Manage DNS with the Cloudflare API - Automate DNS management in your deployment pipeline
Build Your First Static Site
New to static site deployment? Build a real project first with these hands-on tutorials:
- Build a Blog from Scratch - Create a complete blog with vanilla JavaScript, perfect for Workers deployment
- Build a Portfolio from Scratch - Learn web fundamentals while building a professional portfolio
- Build E-Commerce from Scratch - Master JavaScript with a shopping cart application
Each tutorial teaches modern web development fundamentals and your finished project will be ready to deploy to Cloudflare Workers.
Additional Resources
- Cloudflare Workers Documentation
- Wrangler CLI Reference
- GitHub Actions for Wrangler
- Cloudflare API Documentation
Questions about your specific migration? Drop a comment below—I've debugged most edge cases during this blog's migration.
Fred
AUTHORFull-stack developer with 10+ years building production applications. I've been deploying to Cloudflare's edge network since Workers launched in 2017.

