By Shyam Verma

Zero-Downtime Migration: Using Reverse Proxy to Keep WordPress Alive While Building the Future

Zero-Downtime Migration: Using Reverse Proxy to Keep WordPress Alive While Building the Future

Migrating from WordPress to a modern stack without downtime or broken links using a reverse proxy strategy.

The Problem

Traditional migrations force you to choose between risk and speed:

  • Big bang cutover = high risk of broken links and downtime
  • Staging environment testing doesn't catch production issues
  • SEO rankings vulnerable during transition
  • No fallback if something breaks

For sites with established traffic and SEO, this is unacceptable.


The Solution

Keep WordPress running while gradually building your new application. Use a reverse proxy to route traffic intelligently:

User Request → New Next.js App
    ↓
  Page exists? → Serve from Next.js ✅
    ↓
  Page missing? → Proxy to WordPress ✅

Result: Zero broken links, zero downtime.


Implementation

Phase 1: Initial Setup

Deploy your new Next.js app alongside WordPress on the main domain. Start with just a homepage.

Caddy Configuration:

example.com {
    reverse_proxy nextjs:3000
}

WordPress continues serving all unmigrated pages naturally.

Phase 2: Gradual Migration

Migrate content incrementally. As you add pages to Next.js, they automatically serve from the new app. Unmigrated pages still come from WordPress.

Phase 3: WordPress to Subdomain

Once majority of content is migrated, move WordPress to a subdomain as a fallback.

Updated Caddy Configuration:

# Main site - Next.js
example.com {
    reverse_proxy nextjs:3000
}

# WordPress fallback
w.example.com {
    reverse_proxy wordpress-server {
        header_up Host "example.com"
        header_down Location "example.com" "w.example.com"
    }
}

SSL/HTTPS Handling:

CloudFlare handles SSL termination, making this setup much simpler. Both Caddy and WordPress serve content over HTTP internally, while CloudFlare provides HTTPS to users:

User (HTTPS) → CloudFlare (SSL termination) → Caddy/WordPress (HTTP)

This eliminates the complexity of:

  • Configuring SSL certificates on multiple systems
  • Auto-renewal coordination between Next.js and WordPress hosting
  • Certificate management on legacy WordPress hosting (often difficult)

Without CloudFlare, you'd need to configure SSL certificates for both systems, handle auto-renewal, and ensure both Caddy and WordPress hosting support Let's Encrypt or similar.


The Real Struggle: URL Rewriting

The hardest part wasn't the reverse proxy itself—it was getting WordPress to serve content from a subdomain while making it think it was still on the main domain.

Problem: WordPress Knows Its Domain

WordPress has its site URL hardcoded in the database (wp_options table). When you proxy requests:

# First attempt - didn't work
w.example.com {
    reverse_proxy http://wordpress-ip
    header_up Host "example.com"  # Tell WordPress it's the main domain
}

What went wrong:

  • WordPress generated links: https://example.com/page (main domain)
  • User was on: https://w.example.com
  • Clicking any link took user back to Next.js (infinite redirect loop)
  • CSS/JS assets broken (loaded from wrong domain)

Failed Attempt #1: Caddy Header Rewriting

Tried using Caddy's header_down to rewrite response headers:

w.example.com {
    reverse_proxy http://wordpress-ip {
        header_up Host "example.com"
        header_down Location {http.reverse_proxy.header.Location}
    }
}

Result: Headers got rewritten, but HTML content still had example.com URLs everywhere.

Failed Attempt #2: More Caddy Headers

Added multiple header rewrites:

w.example.com {
    reverse_proxy http://wordpress-ip {
        header_up Host "example.com"
        header_up X-Forwarded-Proto "https"
        header_up X-Real-IP {remote_host}

        # Tried rewriting all possible redirect headers
        header_down Location {http.reverse_proxy.header.Location}
    }
}

Result: Still didn't work. HTML body content wasn't being rewritten. Caddy can't do regex replacement in response bodies.

Commit: "caddy cant do it"

After hours of trying, I realized Caddy cannot rewrite HTML content. It can only modify headers, not the response body.

What Finally Worked: Nginx with sub_filter

Switched to nginx alongside Caddy:

# nginx.conf
server {
    listen 80;
    server_name w.example.com;

    location / {
        proxy_pass https://wordpress-ip;
        proxy_set_header Host example.com;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Accept-Encoding identity;  # Critical: disable compression

        # Rewrite URLs in response headers
        proxy_redirect ~^https://example.com(.*)$ https://w.example.com$1;

        # Rewrite URLs in HTML/CSS/JS content
        sub_filter 'https://example.com' 'https://w.example.com';
        sub_filter 'http://example.com' 'https://w.example.com';

        # Handle URL-encoded versions (in JavaScript)
        sub_filter 'https%3A%2F%2Fexample.com' 'https%3A%2F%2Fw.example.com';

        sub_filter_once off;  # Replace ALL instances, not just first
        sub_filter_types text/html text/css text/javascript application/javascript;
    }
}

Key insights:

  1. Accept-Encoding: identity - Disable compression so nginx can read/modify content
  2. sub_filter - Regex replacement in response body (what Caddy can't do)
  3. sub_filter_once off - Replace all occurrences, not just the first one
  4. sub_filter_types - Apply to HTML, CSS, JS files
  5. Handle URL-encoded URLs (JavaScript often has encoded URLs)

Hybrid Setup: Caddy + Nginx

Final architecture used both:

CloudFlare (HTTPS)
    ↓
Caddy (main routing)
    ↓ (for w.example.com only)
Nginx (URL rewriting)
    ↓
WordPress (thinks it's on example.com)

Later: Figured out Caddy can do it with the right syntax:

w.example.com {
    reverse_proxy https://wordpress-ip {
        transport http {
            tls
            tls_insecure_skip_verify
        }

        # Disable compression (critical!)
        header_up Accept-Encoding identity

        # Make WordPress think it's on main domain
        header_up Host "example.com"
        header_up X-Forwarded-Proto "https"
        header_up X-Forwarded-Host {host}

        # Rewrite response headers
        header_down Location {re.replace|https://example.com|https://w.example.com}
        header_down Link {re.replace|https://example.com|https://w.example.com}
        header_down Content-Location {re.replace|https://example.com|https://w.example.com}
        header_down Refresh {re.replace|https://example.com|https://w.example.com}
    }
}

What I learned:

  • Caddy's {re.replace|pattern|replacement} in header_down works for response headers
  • But still can't rewrite HTML body content (nginx's sub_filter is unique)
  • For simple redirects, Caddy header rewriting is enough
  • For full WordPress proxy with links in HTML, nginx sub_filter is better

Production choice: Kept nginx for w.example.com because it handles HTML content rewriting properly.

Next.js 404 Handler:

// app/not-found.tsx
'use client'

import { useEffect } from 'react'
import { usePathname } from 'next/navigation'

export default function NotFound() {
  const pathname = usePathname()

  useEffect(() => {
    const timeout = setTimeout(() => {
      window.location.href = `https://w.example.com${pathname}`
    }, 1000)

    return () => clearTimeout(timeout)
  }, [pathname])

  return (
    <div className="container mx-auto px-4 py-12 text-center">
      <h1 className="text-3xl font-bold mb-4">Page Not Found</h1>
      <p className="text-gray-600 mb-6">
        Redirecting to legacy content...
      </p>
    </div>
  )
}

Update Internal Links:

# In your migrated content, link to WordPress subdomain for unmigrated pages
[Unmigrated Page](https://w.example.com/old-page)

Handle URL Changes

If you change URL structures, add 301 redirects:

// next.config.ts
const nextConfig = {
  async redirects() {
    return [
      {
        source: '/old-path',
        destination: '/new-path',
        permanent: true,
      },
    ]
  },
}

Results

Production migration of 400+ pages over 12 weeks:

  • Zero downtime
  • Zero broken links
  • SEO improved 15%
  • Page load: 4s → 0.8s

Users never noticed the migration was happening.


Why This Works

Gradual Risk Reduction:

  • Migrate one page at a time
  • Test in production with real traffic
  • Catch issues incrementally
  • No panic over deadlines

SEO Preservation:

  • Google always sees content (from either system)
  • No sudden traffic drops
  • Rankings maintained or improved
  • Clean migration path

Business Continuity:

  • Content team keeps working
  • No interruption to operations
  • WordPress backup exists throughout
  • Peace of mind

Tech Stack: Caddy, Next.js 15, WordPress, Directus CMS

When to use: Large content sites (100+ pages) where downtime and SEO risk are unacceptable.

When not to use: Small sites (<20 pages) or greenfield projects.

Ready to Build Something Exceptional?

Let's turn your idea into reality. Whether you need a fractional developer for ongoing work or want to discuss a specific project, I'm here to help.

20+
Years Experience
150K+
Websites Powered
2
Successful Exits
7x
Faster with AI