WordPress to VPS migration checklist for a live business site

Copying files and a database is the easy half. This is the checklist for the other half, in the order the steps have to happen, with the reasoning for each.

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:

  1. TTL not lowered in advance, so rollback is impossible
  2. Only one data sync, so everything created during the migration is lost
  3. WP-Cron left as is, so scheduled work stops
  4. Mail authentication not updated, so email silently goes to spam
  5. Caching applied without exclusions, so a customer sees another customer’s cart
  6. 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.

Dealing with this yourself?

If this describes a system you are responsible for, we can look at the specific case rather than the general one.