
Moving a WordPress site between Linux servers is a solved problem, but most write-ups solve the wrong version of it. They assume you can take the site offline, copy everything, and bring it back up somewhere else. That works for a personal blog. It does not work for a site that takes orders, runs a membership, or serves an audience across enough time zones that there is no quiet hour.
This guide covers how to migrate WordPress to a new server the way it has to work on a production box: a staged migration where the old server stays authoritative until the new one is proven, the bulk copy happens while the site is still live, and the only offline moment is a short freeze measured in minutes.
Everything here is plain Linux tooling. No migration plugin, no control panel, no vendor lock-in.
What you will build
- A full copy of the site on the new server, synced while the old one keeps serving traffic
- A repeatable delta sync so the final cutover moves minutes of changes, not gigabytes
- A tested TLS certificate on the new host before any DNS change
- A DNS cutover with a pre-lowered TTL so propagation takes minutes
- A rollback path that stays valid for a week
Assumed layout: Ubuntu or Debian on both ends, nginx or Apache, PHP-FPM, MariaDB or MySQL, site root at /var/www/example.com, SSH access to both boxes.
Server prerequisites before you migrate WordPress to a new server
Before copying anything, make the destination match the source where it counts.
# On BOTH servers, compare these
php -v
mysql --version
# loaded PHP extensions
php -m | sort
# whichever web server the site runs on
nginx -v
apache2 -v
# confirm you actually have the space
df -h /var/www
Three mismatches cause most post-migration breakage:
PHP minor version. Moving from 7.4 to 8.2 in the same step as the migration means any fatal error could come from either change. Match the source version first, migrate, then upgrade PHP as a separate deliberate step with its own rollback.
Missing PHP extensions. A theme that needs imagick or a plugin that needs soap fails in ways that do not show on the homepage. Compare php -m output on both sides and install the delta before you copy.
MySQL or MariaDB major version and SQL mode. A dump from MySQL 8 restored into MariaDB 10.3 can throw collation errors, especially with utf8mb4_0900_ai_ci, which MariaDB does not know. Check the collation in the dump before you restore it:
grep -m1 "COLLATE=" backup.sql
If you see utf8mb4_0900_ai_ci and the destination is MariaDB, convert during the dump rather than fighting the restore.
Step 1: Lower the DNS TTL first
Do this 24 to 48 hours before the migration. It takes one minute and it prevents more problems than anything else on this list.
dig +nocmd example.com A +noall +answer
The number in the third column is the TTL in seconds, and on most zones it has never been touched:
example.com. 3600 IN A 203.0.113.10
Set that record’s TTL to 300 in your DNS provider. Resolvers cache by the TTL they were last handed, so lowering it now means that when you change the IP later, the world follows within five minutes instead of an hour. Lowering it at cutover time does nothing for resolvers that already cached the old value.
Raise it back to 3600 a day after the migration settles.
Step 2: Bulk sync WordPress files with rsync while the site is live
This is the long part, and there is no reason to be offline for it. rsync copies what it needs and can be re-run cheaply.
# from the OLD server (push). Run it inside tmux so a
# dropped SSH session does not kill the transfer.
tmux new -s migrate
rsync -aHAX --numeric-ids --info=progress2 \
--exclude 'wp-content/cache/' \
--exclude 'wp-content/uploads/backup*' \
--exclude '.git/' \
/var/www/example.com/ [email protected]:/var/www/example.com/
Flag by flag, because these matter:
-aarchive mode: recursive, preserves symlinks, permissions, timestamps-Hpreserves hard links, which some media libraries rely on-A -Xpreserves ACLs and extended attributes, which is what keeps SELinux or setfacl-based permissions intact--numeric-idsstops UIDs being remapped through/etc/passwdon the destination, the classic cause of a site that arrives owned by the wrong user- Excluding cache directories can cut the transfer substantially and the cache regenerates anyway
Run it once for the bulk copy. It can take hours on a large uploads directory and the site stays up the whole time.
Then fix ownership on the destination to match whatever your PHP-FPM pool runs as:
# on the NEW server
chown -R www-data:www-data /var/www/example.com
find /var/www/example.com -type d -exec chmod 755 {} \;
find /var/www/example.com -type f -exec chmod 644 {} \;
chmod 640 /var/www/example.com/wp-config.php
Step 3: Dump and restore the WordPress database with mysqldump
For the first pass, a straightforward dump is fine, since this copy is going to be replaced at cutover anyway.
# on the OLD server
mysqldump --single-transaction --quick --default-character-set=utf8mb4 \
--routines --triggers --events \
wordpress_db | gzip > /root/wp-$(date +%F).sql.gz
--single-transaction is what keeps this non-blocking on InnoDB: it takes a consistent snapshot without locking the tables, so the live site keeps writing while you dump. --quick streams rows rather than buffering the whole result set in memory, which is what stops a large wp_options table from exhausting RAM on a small box.
Ship the dump across:
scp /root/wp-2026-08-21.sql.gz [email protected]:/root/
Then load it on the destination, where the schema is empty:
# on the NEW server
zcat /root/wp-2026-08-21.sql.gz | mysql wordpress_db
Trim the bloat while you are here
Most long-lived WordPress databases carry a large volume of expired transients that serve no purpose after a migration:
SELECT COUNT(*), ROUND(SUM(LENGTH(option_value))/1024/1024, 1) AS mb
FROM wp_options WHERE option_name LIKE '\_transient%';
DELETE FROM wp_options
WHERE option_name LIKE '\_transient_timeout%' AND option_value < UNIX_TIMESTAMP();
Also check autoload size, since every autoloaded row is read on every single request:
SELECT ROUND(SUM(LENGTH(option_value))/1024/1024, 2) AS autoload_mb
FROM wp_options WHERE autoload = 'yes';
Anything above a megabyte or so is worth investigating before it becomes the new server’s performance problem too.
Step 4: Test on the new server without touching DNS
Point only your own machine at the new IP by editing the hosts file on your workstation:
sudo vim /etc/hosts
Add one line pinning the domain and its www form to the destination:
203.0.113.20 example.com www.example.com
Now you browse the new server while the rest of the world still uses the old one. Check the things that break silently:
# on the NEW server, watch logs while you click around
tail -f /var/log/nginx/error.log /var/www/example.com/wp-content/debug.log
Walk the full path: a logged-in admin session, a form submission, a search, an image upload, and a page that hits the database hard. If the site sells anything, complete a transaction with the gateway in test mode.
Get TLS ready before cutover
You cannot complete an HTTP-01 ACME challenge for a domain that still resolves to the old server, which catches people out at exactly the wrong moment. Two clean options.
A DNS-01 challenge does not care where the A record points:
certbot certonly --manual --preferred-challenges dns -d example.com -d www.example.com
Or copy the existing Let’s Encrypt certificate across so the new box can serve HTTPS from the first second:
rsync -aHAX /etc/letsencrypt/ [email protected]:/etc/letsencrypt/
Then re-issue properly after DNS has moved. Either way, do not arrive at cutover with no valid certificate on the destination.
Step 5: Freeze, delta sync, and cut over DNS
Now the short window. This is the only time the site is not accepting writes, and with the bulk copy already done it should be minutes.
# 1. OLD server: enable maintenance mode
printf '<?php $upgrading = time(); ?>' > /var/www/example.com/.maintenance
# 2. OLD server: delta file sync (fast, only what changed since the bulk copy)
rsync -aHAX --numeric-ids --delete --info=progress2 \
--exclude 'wp-content/cache/' \
/var/www/example.com/ [email protected]:/var/www/example.com/
# 3. OLD server: final database dump, now that nothing is writing
mysqldump --single-transaction --quick --default-character-set=utf8mb4 \
--routines --triggers --events wordpress_db | gzip > /root/final.sql.gz
scp /root/final.sql.gz [email protected]:/root/
# 4. NEW server: restore over the top
zcat /root/final.sql.gz | mysql wordpress_db
Note --delete on the delta sync. It removes files on the destination that no longer exist on the source, which keeps a plugin you uninstalled last week from coming back to life on the new server.
Verify before you switch anything:
# NEW server: sanity-check the restore
wp option get siteurl --allow-root --path=/var/www/example.com
wp post list --post_type=post --posts_per_page=5 --allow-root --path=/var/www/example.com
wp db check --allow-root --path=/var/www/example.com
Compare a couple of row counts against the old database. If posts, users, and (if applicable) orders all match, you are clear to switch DNS.
Change the A record to the new IP. With a 300 second TTL, resolvers follow quickly. Then remove maintenance mode on the new server:
rm /var/www/example.com/.maintenance
Leave the old server running with the site disabled for at least a week. Do not delete it. It is your rollback, and if something surfaces on day three you want its state exactly as it was at cutover.
Gotchas you will hit on the first migration
Hardcoded URLs in the database. If the domain changes as part of the move, a plain find-and-replace corrupts PHP serialized data, because serialized strings store their own length. Use a serialization-aware tool:
wp search-replace 'https://old.example.com' 'https://example.com' \
--all-tables --precise --skip-columns=guid --allow-root
Run it with --dry-run first. Skip the guid column: it is an identifier, not a link, and rewriting it makes feed readers treat every historical post as new.
Cron stops firing. WordPress cron only runs when someone visits the site, which is unreliable on a low-traffic site and doubly so right after a migration.
sudo vim /var/www/example.com/wp-config.php
Add the constant above the line that requires wp-settings.php:
define('DISABLE_WP_CRON', true);
Then hand the schedule to the system cron daemon instead:
sudo vim /etc/cron.d/wp-cron
One line is enough, and note that a cron.d entry carries the user field:
*/5 * * * * www-data /usr/bin/php /var/www/example.com/wp-cron.php >/dev/null 2>&1
Outbound email silently dies. A new IP has no sending reputation, and your SPF record almost certainly still authorizes only the old server. Update SPF and DKIM before cutover, and route transactional mail through an authenticated relay rather than the local MTA.
Opcache serves the old code. After a large file sync, PHP may still be executing cached bytecode from before the copy:
systemctl reload php8.2-fpm
File ownership drift. If you forgot --numeric-ids, uploads may be owned by a UID that does not exist on the destination. Symptom: media library uploads fail with no useful error. The chown -R in Step 2 fixes it.
A stray noindex. If the site was ever staged behind a “discourage search engines” setting, verify it before you walk away:
curl -sI https://example.com | grep -i x-robots-tag
wp option get blog_public --allow-root
blog_public must be 1. This is the mistake that costs the most and announces itself the least, because the site works perfectly while quietly leaving the index.
Post-migration verification
Give it 48 hours of attention:
# Confirm the world sees the new IP
dig +short example.com @8.8.8.8
dig +short example.com @1.1.1.1
# Watch for PHP errors that only appear under real traffic
journalctl -u php8.2-fpm -f
tail -f /var/log/nginx/access.log | grep -E ' (4|5)[0-9]{2} '
# Confirm TLS is valid and chains correctly
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates -subject
Then confirm the backup system actually runs on the new host. Backups do not migrate themselves, and a freshly moved server with no backup job is the single most common way a clean migration turns into a bad month.
When it is worth handing off
This process is entirely doable by anyone comfortable on a terminal, and if that is you, the commands above are the whole job. The reason people outsource it is not difficulty, it is timing: the freeze window lands at 3am, the rollback decision has to be made quickly under pressure, and someone has to be watching logs afterwards rather than going to bed.
If a site is business-critical and nobody wants to own that window, a managed host will run this sequence as a supervised procedure. CoHosta’s WordPress migration service copies the site to the new server, tests it there, and switches DNS last, with the move scheduled for the site’s quietest hours.
Where to go from here
Once the site is stable on the new box, the follow-up work is the part that pays off later: put the nginx and PHP-FPM configs under version control, script the delta sync so the next migration is a re-run rather than a rediscovery, and set up monitoring that tells you about a 500 before a customer does. Wrapping that sync in a systemd timer turns it into a scheduled job instead of a command someone has to remember.
A migration done in this order is boring, and boring is the goal. The site stays up during the slow part, the offline moment is short and deliberate, and the old server sits there ready to take over if anything looks wrong.
This guide was contributed by Qays Zubaidi.