Most WordPress migration guides describe how to copy files and a database. That part rarely fails.
What fails is the cron job nobody documented, the mail that silently stops being delivered, the orders placed while the database was being copied, and the DNS record with a 24 hour TTL that turns a five minute rollback into a day of split traffic.
This is the checklist for that half, in the order the steps have to happen.
Before you touch anything
Lower the DNS TTL first
This is step one and it is the step most commonly skipped, because its effect is invisible until you need it.
TTL tells resolvers how long to cache your record. If your A record has a TTL of 86400, some resolvers will keep serving the old address for up to a day after you change it. That is fine going forward and catastrophic going backward: if you need to roll back, you cannot.
Drop the TTL to 300 seconds at least 24 hours before the migration, so every cached copy of the old long TTL has expired by the time you cut over. Put it back up after the migration is confirmed.
# Check what you are currently publishing
dig +noall +answer example.com A
dig +noall +answer www.example.com A
# And what resolvers are actually caching, which can differ
dig +noall +answer @8.8.8.8 example.com A
Inventory what is actually running
Do not trust anyone’s description of the server, including your own. Look:
# What is listening, and which process owns it
ss -tulpanH
# Everything scheduled, including the per-user crontabs people forget
for u in $(cut -f1 -d: /etc/passwd); do crontab -l -u "$u" 2>/dev/null | sed "s/^/$u: /"; done
cat /etc/crontab; ls -la /etc/cron.d/
systemctl list-timers --all
# How large is this actually
du -sh /var/www/*/wp-content/uploads
mysql -e "SELECT table_schema, ROUND(SUM(data_length+index_length)/1024/1024) AS mb
FROM information_schema.tables GROUP BY table_schema;"
The uploads directory size matters more than people expect. A 40 GB uploads directory changes the migration plan, because you cannot copy it during the cutover window. It has to be synchronized in advance and then topped up.
Find out how mail leaves the server
This is the single most commonly broken thing after a WordPress migration, and it breaks silently.
# Is there a local MTA, and what is it
ss -tlnp | grep ':25'
systemctl status postfix exim4 sendmail 2>/dev/null | head -20
# What does the current SPF record authorize
dig +short TXT example.com | grep -i spf
If WordPress sends through an external provider using SMTP credentials, migration is easy. If it
uses PHP mail() through a local MTA, the sending IP is about to change, and your SPF record
almost certainly does not cover the new one.
The copy
Files
Use a tool that can resume and can run a second pass cheaply. rsync over SSH is the standard
answer for a reason.
# First pass, days ahead. Slow, and it does not matter.
rsync -aHz --delete --exclude 'wp-content/cache/' \
-e ssh /var/www/example.com/ deploy@new-server:/var/www/example.com/
Run this well before cutover. The second pass at cutover then only has to move what changed, which turns hours into seconds.
Database
# Single-transaction keeps InnoDB tables consistent without locking the site
mysqldump --single-transaction --quick --routines --triggers \
--default-character-set=utf8mb4 wordpress_db | gzip > wp-$(date +%F).sql.gz
--single-transaction matters on a live site. Without it you either lock tables, taking the site
down, or take an inconsistent dump where one table reflects a state the next one does not.
Search and replace the site URL safely
Here is the WordPress-specific trap that catches people who have done plenty of migrations on other platforms.
WordPress stores serialized PHP arrays in the options and meta tables. Serialized strings encode their own length:
a:1:{s:9:"site_url";s:19:"https://example.com";}
^^ the 19 is the byte length
A plain SQL REPLACE() changing https://example.com to https://newexample.com leaves the length
prefix saying 19 when the string is now 22 bytes. PHP cannot unserialize it, the option returns
false, and a theme setting or plugin configuration silently reverts to default. It usually is not
noticed until someone asks why the widget disappeared.
Use a tool that understands serialization:
# WP-CLI handles serialized data correctly
wp search-replace 'https://old.example.com' 'https://example.com' \
--all-tables --precise --report-changed-only --dry-run
Run with --dry-run first, always. Then without it.
If you are only staging the site temporarily, do not search and replace at all. Test through a hosts file entry instead, so the database keeps the production URL and there is nothing to undo.
# On your own machine, /etc/hosts
203.0.113.10 example.com www.example.com
Build the destination properly
A VPS is only faster than shared hosting if it is configured. An untuned VPS with default PHP settings can genuinely be slower than the host you left.
Size PHP-FPM for the site, not for the RAM
The default pm.max_children is frequently wrong in both directions. Measure the actual memory a
PHP process uses on your site, then divide the memory you are willing to give PHP by that number.
# Average real memory per PHP-FPM worker, in MB
ps --no-headers -o rss -C php-fpm8.2 | awk '{sum+=$1; n++} END {print sum/n/1024}'
If a worker averages 80 MB and you will allocate 2 GB to PHP, pm.max_children is roughly 25. Set
it to 150 because the box has memory and the first traffic spike will exhaust the RAM and take the
whole server down with the OOM killer.
Add an object cache
This is usually the single biggest win of moving to a VPS, and it is unavailable on most shared hosting. WordPress caches options, post meta and term data per request by default. A persistent object cache keeps that between requests.
sudo apt install redis-server php8.2-redis
wp plugin install redis-cache --activate
wp redis enable
wp redis status
Get the caching exclusions right before any traffic arrives
If you add page caching on the new server, exclude the paths that must never be cached. Getting this wrong on a store is not a performance bug, it is a data exposure.
# Never serve a cached response to a logged-in user or an active cart
map $http_cookie $skip_cache {
default 0;
"~*wordpress_logged_in_" 1;
"~*woocommerce_items_in_cart" 1;
"~*wp-postpass_" 1;
}
# And never cache these paths at all
location ~* ^/(wp-admin|wp-login\.php|cart|checkout|my-account) {
set $skip_cache 1;
}
Test the things that fail quietly
Before cutover, on the destination, through a hosts file entry. Not the homepage. Everyone tests the homepage.
- Admin login, including a password reset email arriving
- A real checkout with the payment gateway in test mode, end to end
- A contact form submission, and confirm the email arrives rather than assuming
- Scheduled publishing, by scheduling a post two minutes out and watching it publish
- Search, which frequently depends on a database configuration people forget to replicate
- File upload in the admin, which catches permission and ownership mistakes
- A 404 page and a redirect, to confirm the rewrite rules came across
Replace WP-Cron with a real cron entry
WordPress schedules tasks through wp-cron.php, which fires on page loads. On a low-traffic site,
scheduled tasks run late or not at all. On a high-traffic site, it fires constantly and wastes
resources.
A migration is the right moment to fix this, because you are already changing the server.
// wp-config.php
define( 'DISABLE_WP_CRON', true );
# Then a real cron entry
*/5 * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now >/dev/null 2>&1
WordPress documents this approach directly. It is the difference between “scheduled” and “scheduled if somebody happens to visit.”
Cutover
The second sync is not optional
Between your first copy and the DNS change, the old site kept working. Orders were placed. Comments were posted. Files were uploaded. On a store, this is money.
# Immediately before the DNS change
rsync -aHz --delete --exclude 'wp-content/cache/' \
-e ssh /var/www/example.com/ deploy@new-server:/var/www/example.com/
mysqldump --single-transaction --quick wordpress_db | \
ssh deploy@new-server "mysql wordpress_db"
For a busy store, either take a short read-only window so the final dump is consistent, or plan to reconcile orders after cutover. What you must not do is assume the gap was empty.
Then change DNS
Not before. The destination should be fully verified first.
Keep the source alive
Do not cancel the old server. Do not modify it. It is your rollback for the next several days, and it is the only place to recover something you discover is missing.
After cutover
Fix mail authentication immediately
Your sending IP changed. Until SPF reflects that, transactional email starts failing authentication, and receipts and password resets go to spam.
v=spf1 ip4:203.0.113.10 include:_spf.google.com -all
Then actually test it, rather than reading the record and assuming:
echo "test" | mail -s "post-migration check" [email protected]
Open the received message and inspect the headers for spf=pass and dkim=pass. If you use
DMARC, check that alignment holds too.
Verify cron is running
wp cron event list
grep CRON /var/log/syslog | tail
Watch for a few days
- Server error logs, for anything appearing that did not before
- Search Console crawl errors and index coverage
- Order volume against the same period last week, which is the fastest way to notice a broken checkout
- Disk usage, because a misconfigured log or backup on a new server fills a disk surprisingly fast
Raise the TTL again
Once you are confident, put the TTL back to something normal. A 300 second TTL forever means far more DNS queries than you need.
The short version
The failure modes are consistent and all of them are preventable:
- TTL not lowered in advance, so rollback is impossible
- Only one data sync, so everything created during the migration is lost
- WP-Cron left as is, so scheduled work stops
- Mail authentication not updated, so email silently goes to spam
- Caching applied without exclusions, so a customer sees another customer’s cart
- Source server destroyed too early, so there is nothing to go back to
None of these are hard. They are just invisible until the moment they matter, which is why they belong on a checklist rather than in someone’s memory.