Most WordPress migrations are not technically hard. They are operationally fragile. The files copy, the database imports, and the site loads — until a serialized widget loses its configuration, a stale object cache serves the old URL, or a payment webhook keeps posting to the old environment. The failure is rarely the transfer. It is the sequence around it.
This runbook assumes a single WordPress site or a WooCommerce store on a conventional LEMP stack. Multisite and headless setups need additional steps. The goal is a cutover where visitors see no failure, writers are frozen for only a few minutes, and you can roll back without reconstructing the site from a backup.
- Lower DNS TTL 48 hours early. Do not discover propagation during the window.
- Stage the destination first. Test the host, PHP version, SSL, cron, and email before the real cutover.
- Freeze writes, then sync the delta. A final database export after the freeze is the source of truth.
- Search-replace with a serialization-aware tool. A raw SQL replace will corrupt widget and theme data.
- Test checkout, email, and webhooks. A homepage that loads is not a migration that succeeded.
- Keep the old site for 72 hours. Rollback is a plan, not a hope.
1. Prepare the destination before touching DNS
Provision the new host and install a clean WordPress at the same version. Match PHP, database, and web server settings as closely as the platform allows. If the destination uses a different PHP major version, test the plugin set on staging first; a migration is a bad time to discover a deprecated function.
Create a staging URL on the destination, usually a temporary hostname or an IP with a hosts-file override. Do not point the live domain at the new server until the clone is complete and tested. Keep the old site live and untouched during this phase.
# On the destination, confirm the runtime before importing anything
php -v
wp --info
mysql --version
nginx -v
# Confirm the WordPress CLI is available and the site is reachable
wp core version --path=/var/www/site
curl -I https://staging.example.com
Record the source environment: WordPress version, PHP version, active plugins, table prefix, uploads size, database size, cron configuration, and any server-level redirects. This inventory is what you will compare against after the import.
2. Lower DNS TTL 48 hours ahead
Set the A and AAAA records to a 300-second TTL at least 48 hours before the window. If your previous TTL was 24 hours, wait for it to expire before assuming the new value is active. Check from multiple resolvers, not just your laptop.
If you use a CDN, lower the TTL at the authoritative DNS provider and confirm the CDN's origin configuration can be switched independently. A CDN in front of the old origin can mask a DNS change for hours if its own cache and origin bindings are not part of the plan.
3. Clone files and database
Copy the files first, then the database. For the initial clone, a cold copy is fine because the old site is still serving. Use rsync with a trailing slash on the source to copy contents rather than the directory itself. Exclude caches and logs that should not travel.
# Initial file sync (old host -> new host)
rsync -avz --delete \
--exclude='wp-content/cache/' \
--exclude='wp-content/uploads/cache/' \
--exclude='*.log' \
/var/www/old/ user@new-host:/var/www/new/
# Export the database from the old host
wp db export /tmp/site-initial.sql --path=/var/www/old
# Import on the new host
wp db import /tmp/site-initial.sql --path=/var/www/new
If the uploads directory is large, run the initial rsync early and repeat it during the change window. The second run only transfers the delta. Do not delete the old files until the new site has been stable for the rollback window.
4. Search-replace the URL — correctly
WordPress stores serialized PHP arrays in the database. A naive `UPDATE ... REPLACE()` changes string lengths without updating the serialization metadata, which silently breaks widgets, theme options, and some plugin settings. Use WP-CLI's search-replace, which handles serialization, or a dedicated tool that does the same.
# Dry run first. Always.
wp search-replace 'https://old.example.com' 'https://new.example.com' \
--all-tables --precise --dry-run --path=/var/www/new
# Apply once the report looks correct
wp search-replace 'https://old.example.com' 'https://new.example.com' \
--all-tables --precise --path=/var/www/new
# If the site uses protocol-relative or http:// URLs, handle them explicitly
wp search-replace 'http://old.example.com' 'https://new.example.com' \
--all-tables --precise --path=/var/www/new
Check the `wp_options` rows for `siteurl` and `home`, and confirm `wp-config.php` does not hard-code the old domain. If you use a custom `WP_HOME` or `WP_SITEURL`, update the constant or the environment variable. Flush the object cache after the replace; a persistent cache can keep the old URLs alive even after the database is correct.
Do not skip the dry run. Search-replace is the highest-risk step in the migration. A wrong table prefix or an overly broad match can rewrite order metadata or plugin configuration. Review the counts before applying.
5. Test the clone before the cutover
Point a hosts-file entry or a temporary DNS record at the new server and walk the site as a logged-out visitor, a customer, and an administrator. The following are non-negotiable:
- Homepage, a deep page, search results, and the 404 page.
- Login, password reset, and the admin dashboard.
- Add to cart, coupon, shipping calculation, checkout, and a test order.
- Order confirmation email and any transactional email.
- Cron events, scheduled posts, and Action Scheduler jobs.
- SSL on apex and www, canonical URLs, sitemap, and robots.txt.
- Payment webhooks and any external integration pointed at the new URL.
If the store uses a payment gateway with a webhook URL, update it in the gateway dashboard only after the cutover, or use a gateway sandbox that accepts both environments during the test. A webhook that still points to the old site will mark orders paid in the wrong database.
6. The cutover window
The cleanest small-team cutover is a short write freeze, not a long outage. Put the old site into a maintenance or read-only mode, take a final database export, import it on the destination, run the final search-replace, switch DNS, and test. Visitors may see a maintenance page for a few minutes; they should never see a broken checkout.
Announce and verify
Confirm the rollback owner, the maintenance page, the DNS credentials, and the test-order card. Check that the destination has enough disk space for the final import.
Freeze writes
Enable maintenance mode or a read-only plugin. Stop cron on the old site. Confirm no orders are mid-checkout.
Final delta
Export the old database, import it on the destination, rsync uploads, run the final search-replace, and flush all caches.
Switch DNS
Update the A/AAAA records. Keep the old environment running. Watch the new origin's error log and the synthetic checkout.
Verify and unfreeze
Place a real test order, confirm email and webhook delivery, then remove maintenance mode and re-enable cron on the new site.
Retire the old site
Keep the old environment available but idle. After three clean days, take a final backup and decommission it.
7. The rollback plan
A rollback is not “restore the backup.” It is a tested sequence with an owner and a trigger. Define the trigger before the window: a checkout failure rate above 1%, a payment webhook failure, a database error that persists for five minutes, or an SSL failure on the primary domain. If the trigger fires, revert DNS, restore the old site's write state from the final snapshot, and communicate.
Keep the old database export and the old environment intact for at least 72 hours. If orders were placed on the new site during the failed window, export those orders before reverting so they can be reconciled. A rollback that loses orders is worse than a delayed cutover.
8. After the move
Re-enable cron on the new site and disable it on the old one. Check that scheduled posts publish, backups run, and the uptime monitor is watching the new origin. Update external services: payment webhooks, email authentication (SPF/DKIM), analytics, search console, and any integration that whitelists an IP.
Run a performance baseline after 24 hours and compare it with the old host. A migration is also a free before/after experiment: if TTFB or checkout latency got worse, investigate before the old environment is retired.
The pre-flight checklist
| Area | Check | Owner |
|---|---|---|
| DNS | TTL at 300 s for 48 h; registrar access confirmed | Ops |
| Files | Initial rsync complete; uploads size matches; permissions correct | Dev |
| Database | Import clean; table prefix correct; search-replace dry run reviewed | Dev |
| Checkout | Test order, coupon, shipping, confirmation email, webhook | Commerce |
| SPF, DKIM, DMARC; transactional sender verified | Ops | |
| SSL | Apex and www certificates valid; mixed content checked | Ops |
| Cron | Old cron stopped; new cron running; Action Scheduler healthy | Dev |
| Rollback | Old site intact; final snapshot; DNS revert tested | Lead |
| Comms | Maintenance notice drafted; support team briefed | Lead |
Print this table, assign an owner to every row, and do not start the window with an unchecked box. The cost of a 15-minute delay is trivial compared with a failed checkout.
Five mistakes that cause the most damage
- Lowering TTL at the last minute. Resolvers keep the old record; some visitors hit the old site for hours.
- Using a raw SQL search-replace. Serialized data breaks silently. Use a serialization-aware tool.
- Forgetting the object cache. The database is correct, but Redis still serves the old URL or stale options.
- Testing only the homepage. Checkout, email, and webhooks are where migrations fail commercially.
- Retiring the old host too early. Keep it through the rollback window; disk is cheaper than a reconstruction.
If you only remember one thing: the DNS switch is not the migration. The migration is the final write freeze, the database delta, the serialization-safe replace, and the checkout test. Everything before that is rehearsal.