Cart
Net 9 regions NRT 232 ms SYD 264 ms AMS 12 ms Uptime 30d 99.997 %
All posts

Guides

Migrating 30+ WordPress sites from shared hosting without breaking them

Omega Digital 10 min read

A reseller onboarding came in with 34 WordPress sites on a cPanel shared plan that was about to expire. Here is the exact process we use so nothing breaks: TTL pre-staging, rsync strategy, database search-replace, and the validation checklist that catches the broken ones.

Contents

  • · Week before: lower DNS TTLs
  • · Inventory every site before touching anything
  • · Move files: rsync beats cPanel backup archives
  • · Move the database: mysqldump, not phpMyAdmin
  • · Search-replace the URL in wp_options and everywhere else
  • · Custom paths, symlinks, and the things that quietly break
  • · Cut over, then the post-migration validation checklist
  • · What we got wrong the first few times

WordPress migrations look simple until the 27th site of the day surfaces a serialized array of widget settings that nobody ever touched, pointing at an old absolute path, rendering half a homepage blank. We have done enough of these now that we wrote the process down. This is that writeup.

The context: a reseller came to us with 34 WordPress sites on a single cPanel shared account. The plan was expiring in 9 days. We had to move everything, keep URLs the same, keep email working, and not be the reason a client noticed.

Week before: lower DNS TTLs

The single highest-leverage move is boring: lower the TTL on every A/AAAA and MX record at least 48 hours before the migration window. Most shared hosts leave TTLs at 14400 (4 hours) or 86400 (24 hours). If you cut over with a 24-hour TTL you will see traffic and mail hit the old server for a full day while caches expire. Drop TTLs to 300 seconds a few days before cutover, and you can roll forward or back in minutes.

bash
# Check current TTLs before you touch anything
for d in site1.com site2.com site3.com; do
  dig +noall +answer "$d" A | awk '{print $1, $2}'
done

# After the change propagates, verify TTL actually dropped
dig +short +ttl site1.com A

If the registrar is slow, do this first and come back to it on migration day. Raise the TTLs back to 3600 or 14400 a week after cutover once everything is stable.

Inventory every site before touching anything

Do not start copying files until you have a spreadsheet. For each site: the document root path, the database name and user, the PHP version the site actually runs under, whether it uses a custom wp-content path, whether multisite is enabled, and the cron situation (real cron vs wp-cron.php on pageload). Plugins that hardcode absolute paths are the usual source of post-migration pain, and an inventory is the only way to know what you are dealing with.

bash
# From the old server: dump a per-site summary
for d in /home/*/public_html/*/; do
  site=$(basename "$d")
  size=$(du -sh "$d" 2>/dev/null | cut -f1)
  wpver=$(grep -oP "wp_version\s*=\s*'\K[^']+" "$d/wp-includes/version.php" 2>/dev/null)
  dbname=$(grep -oP "DB_NAME',\s*'\K[^']+" "$d/wp-config.php" 2>/dev/null)
  printf '%-40s  %-8s  wp=%-8s  db=%s\n' "$site" "$size" "$wpver" "$dbname"
done

Move files: rsync beats cPanel backup archives

The tempting approach is to generate a full cPanel backup, download the tarball, upload it to the new server, and extract. It works. It is also slow, produces a point-in-time snapshot that goes stale the moment you make it, and bundles data you do not want (mail spool, access logs, AWStats) alongside data you do. For WordPress specifically, rsync over SSH is faster, resumable, and lets you do an incremental sync at cutover to catch any files that changed during the first pass.

bash
# Initial sync: pull files from old host to new (run on the new server)
rsync -aHvz --delete \
  --exclude='wp-content/cache/' \
  --exclude='wp-content/uploads/backup-*/' \
  --exclude='.htaccess.bak' \
  --exclude='error_log' \
  -e "ssh -p 22 -o StrictHostKeyChecking=accept-new" \
  user@old-host:/home/user/public_html/site.example/ \
  /var/www/site.example/

# At cutover, re-run with the same flags — only diffs transfer
rsync -aHvz --delete \
  --exclude='wp-content/cache/' \
  -e "ssh -p 22" \
  user@old-host:/home/user/public_html/site.example/ \
  /var/www/site.example/

Notes on the flags. `-a` preserves permissions, timestamps, and symlinks. `-H` preserves hardlinks, which matters if the site uses them for plugin updates. `--delete` removes files on the destination that no longer exist at the source; without it, files deleted between your first and second sync will still be present on the new server. The `--exclude` list keeps cache directories, backup plugin dumps, and rotated logs out of the transfer. We have seen single sites with 8 GB of `wp-content/cache/` that did not need to move.

Move the database: mysqldump, not phpMyAdmin

phpMyAdmin has a habit of timing out or truncating large exports. Use mysqldump directly. If you cannot SSH to the old host, cPanel usually exposes an SSH terminal under Advanced; failing that, run mysqldump from the new server over the remote connection.

bash
# On the old server (if you have SSH)
mysqldump \
  --single-transaction \
  --quick \
  --skip-lock-tables \
  --default-character-set=utf8mb4 \
  --set-gtid-purged=OFF \
  -u wp_user -p wp_dbname > /tmp/site.sql

# Compress and transfer
gzip /tmp/site.sql
scp /tmp/site.sql.gz new-server:/tmp/

# On the new server
gunzip /tmp/site.sql.gz
mysql -u wp_user -p wp_dbname < /tmp/site.sql

`--single-transaction` gives you a consistent snapshot on InnoDB without locking the table for writers. `--default-character-set=utf8mb4` is non-negotiable: MySQL's default `utf8` is a 3-byte subset that mangles emoji and any 4-byte character. We have fixed that specific corruption more times than we would like to admit.

Search-replace the URL in wp_options and everywhere else

If the URL stays the same (the normal case for a host migration) you can skip this step. If anything changes (staging to prod, http to https, domain rename), do not run a SQL `UPDATE` against the database. WordPress stores serialized PHP arrays in many places (widgets, theme mods, some plugin options) and a naive string replace will leave you with arrays whose length prefixes no longer match, silently breaking features until someone notices the footer widget vanished.

Use `wp search-replace` from WP-CLI. It understands serialized data.

bash
# Dry run first — always
wp search-replace 'https://old.example' 'https://new.example' \
  --all-tables --dry-run --report-changed-only

# Then for real
wp search-replace 'https://old.example' 'https://new.example' \
  --all-tables --skip-columns=guid --report-changed-only

# If the old site ran on http and the new one is https, do that too
wp search-replace 'http://new.example' 'https://new.example' \
  --all-tables --skip-columns=guid

`--skip-columns=guid` is important. The `guid` field in `wp_posts` is an immutable identifier, not a URL. WordPress documentation is explicit that you should not rewrite it. Rewriting guids invalidates RSS feed entries for subscribers and can cause duplicate-content issues in readers.

WordPress has enough constants in wp-config.php that a site can be configured in ways you will not notice from the outside. Read the target wp-config.php before you assume the layout.

  • · `WP_CONTENT_DIR` and `WP_CONTENT_URL`: if these are set to absolute paths, they will not exist on the new server until you create them
  • · `WP_PLUGIN_DIR` / `WP_PLUGIN_URL`: same issue
  • · `UPLOADS`: if the uploads directory has been moved off `wp-content/uploads`, rsync needs to know
  • · `WPCACHEHOME` (WP Super Cache): absolute path baked in at install time
  • · Symlinks inside `wp-content/plugins` pointing to a shared must-use plugins directory used across sites
  • · `.htaccess` rewrite rules that reference absolute filesystem paths

For multisite installs, also check `DOMAIN_CURRENT_SITE` and `PATH_CURRENT_SITE` in wp-config.php, and the `wp_blogs` and `wp_site` tables. The domain is stored per-blog and will need updating if it changed.

Cut over, then the post-migration validation checklist

Cutover order matters. With TTLs already low, the sequence is: final rsync delta, final mysqldump-and-import, update DNS A records to the new server, wait for propagation, run validation. Do not disable the old site yet. Keep it read-only for at least 72 hours so you can pull anything you missed.

The validation checklist is what catches the quiet failures. Run every item on every site, not just the obvious ones.

  • · Home page loads with no PHP errors in the response (`curl -I` should return 200, not 500)
  • · Logged-in admin dashboard loads (cookies tied to the new domain)
  • · Permalinks resolve: pick a non-home URL and confirm it returns 200, not 404
  • · Upload a test file via Media Library: confirms filesystem perms are sane
  • · Post a comment, or at least load the comments section without a fatal
  • · Any contact form on the site submits and produces the email it should
  • · Check `wp-cron` is firing: `wp cron event list` should show events scheduled in the future, not all in the past
  • · Check HTTPS chain: `curl -I https://site/` returns 200 with no cert warnings
  • · Look at the error log from the last 15 minutes (empty is the goal)
  • · `wp plugin list --status=active` matches what was active on the old site (count and names)
bash
# Quick one-liner we use for a batch of sites
for site in /var/www/*/; do
  domain=$(basename "$site")
  code=$(curl -s -o /dev/null -w "%{http_code}" "https://$domain/")
  admincode=$(curl -s -o /dev/null -w "%{http_code}" "https://$domain/wp-admin/")
  printf '%-40s  home=%s  admin=%s\n' "$domain" "$code" "$admincode"
done

What we got wrong the first few times

We used to skip the TTL pre-lowering step on small sites to save time. It cost us more than it saved every single time: we would see inconsistent behaviour for a day post-cutover as caches expired at different rates, and clients would ping us convinced the migration was broken. Always lower the TTL.

We used to run `wp search-replace` without `--skip-columns=guid`. Nothing visibly broke on day one, but a subscriber flagged three weeks later that their RSS reader had shown every old post as new. It was the guid rewrite.

We used to treat mail as someone else's problem. It is not. If you move the A record before updating MX, and the old host is hosting mail, inbound mail will start trying to deliver to the new IP, which has no mail server listening. Confirm MX records point where mail actually lives before flipping A records, especially for the bundled-mail-with-hosting shared plans.

A migration is not done when the new site loads. It is done when you stop hearing about it.

For the reseller with 34 sites, this process took roughly 11 hours of active work spread over four days, with zero customer-visible downtime. The longest site took 40 minutes; the shortest took 6. For related material, see our notes on wp-config hardening and our customer backup policy, both linked from the guides section.

OD

Omega Digital

Platform · Omega Digital