Migrating from Cloudflare Pages to Workers: A Complete Guide with Zero-Downtime Domain Switchover

Fred· AI Engineer & Developer Educator14 min read

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.com pointing 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.com pointing to Workers deployment
  • Method: Push to GitHub → GitHub Actions → Wrangler deploy

Prerequisites

Before starting:

  1. Cloudflare account with your domain
  2. Existing Pages deployment (the one you're migrating from)
  3. Node.js 18+ installed
  4. Cloudflare API token with "Edit Workers" permissions
  5. Git repo for your static site

Part 1: Setting Up Workers Configuration

Step 1: Install Wrangler

Add Wrangler to your project:

npm install -D wrangler

Step 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 script
  • bucket: Where your static files are (Gatsby uses public, Next.js uses out)

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-handler to 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-handler

Step 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 deploy

This 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: deploy

Get 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 token
  • CLOUDFLARE_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/*.sh

Run it:

./scripts/1-find-old-project.sh YOUR_API_TOKEN

Find the project name that has your domain. Example output:

Project: my-old-site
Domains: my-old-site.pages.dev, yourdomain.com

Script 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-name

Downtime: 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 browser

Part 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:

  1. Cloudflare Dashboard → SSL/TLS → Edge Certificates
  2. 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:

  1. Workers & Pages → old project name
  2. Settings → scroll to bottom
  3. "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:

  1. GitHub Actions runs
  2. Build succeeds
  3. Deploys to Workers
  4. 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=true

Also lock Node version with .nvmrc:

20

Issue 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.toml with correct project name
  • Create workers-site/index.js with static asset handler
  • Install @cloudflare/kv-asset-handler
  • Update package.json scripts to use wrangler 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:

  1. Set up Workers with wrangler.toml and the asset handler script
  2. Configure CI/CD with GitHub Actions
  3. Test everything on the preview URL
  4. Switch the domain atomically via API
  5. 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:

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
fi

Important 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:

  1. Workers routes take precedence over DNS
  2. No propagation delay (instant edge updates)
  3. No CNAME target issues (can't CNAME to workers.dev directly)
  4. 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:

Build Your First Static Site

New to static site deployment? Build a real project first with these hands-on tutorials:

Each tutorial teaches modern web development fundamentals and your finished project will be ready to deploy to Cloudflare Workers.

Additional Resources

Questions about your specific migration? Drop a comment below—I've debugged most edge cases during this blog's migration.

Fred

Fred

AUTHOR

Full-stack developer with 10+ years building production applications. I've been deploying to Cloudflare's edge network since Workers launched in 2017.