Every hosting engineer eventually gets handed the same task: "move these accounts to the new server, and don't let anyone notice." It sounds simple. It goes wrong in the same four or five places every time.
This is the runbook I actually follow. It assumes cPanel/WHM on both ends, but the shape of it — audit, stage the DNS, sync twice, verify, keep a way back — applies to any migration you've been asked to do without a maintenance banner.
The core idea: "zero downtime" doesn't mean the sites never move. It means that at every single moment during the move, some correct server is answering the request. That's achievable. Instantaneous DNS is not.
1. The pre-flight audit
The migration starts several days before the window, at a keyboard, writing things down. Everything below has bitten me at least once when I skipped it.
- Disk space on the destination. Not "roughly enough" — measured, with 25% headroom for the transfer overhead and the delta sync.
- PHP versions in use per account. A site running PHP 7.4 that lands on a server whose oldest handler is 8.2 is a white screen, not a migration.
- Custom software outside the account. Cron jobs referencing absolute paths, custom Apache includes, manually-installed PECL modules, anything in
/opt. - Databases the panel doesn't know about. Anything created directly in MySQL rather than through cPanel won't come with the account.
- Third-party DNS. If the customer's nameservers are at Cloudflare or their registrar, you can't cut over by editing a zone locally. Find this out now.
- Mail volume and mailbox sizes. A 60 GB mailbox will dominate your transfer time. Plan for it separately.
- Who to tell. Which customers need notice, and who signs off on the window.
A quick inventory pass on the source server:
# Account list with disk usage, largest first
whmapi1 listaccts | grep -E 'user:|diskused:'
# PHP version per domain (MultiPHP)
whmapi1 php_get_vhost_versions | grep -E 'vhost:|version:'
# Anything scheduled outside the panel
for u in $(ls /var/cpanel/users); do
echo "--- $u"; crontab -l -u "$u" 2>/dev/null
done
# Databases MySQL knows about vs. cPanel knows about
mysql -e "SHOW DATABASES;" | sort > /tmp/db-mysql.txt
whmapi1 list_mysql_databases_and_users | grep -oP '(?<=database: ).*' | sort > /tmp/db-panel.txt
comm -23 /tmp/db-mysql.txt /tmp/db-panel.txt # orphans live here
That last command has saved me more than once. Orphaned databases are invisible to the account transfer tool and very visible to the customer whose application stops connecting.
2. Drop your TTLs — days early
This is the single step that separates a smooth cutover from a 48-hour tail of "some of my users see the old site". DNS caches for as long as the TTL says. If your A record has a 24-hour TTL when you cut over, some resolvers will keep sending traffic to the old server for the next 24 hours.
So: at least 48 hours before the window, drop the TTL on every record you're going to change to 300 seconds. Resolvers need to expire the old long TTL before they'll honour the new short one — which is exactly why you can't do this on the day.
# Ask the authoritative nameserver directly, not your local resolver
dig +noall +answer @ns1.example.com example.com A
# And check what the world currently sees
dig +noall +answer example.com A @8.8.8.8
dig +noall +answer example.com A @1.1.1.1
Don't forget the MX and mail records. People obsess over the A record and then wonder why mail kept arriving at the old server for a day. TTLs on MX, autodiscover, webmail and any SPF/DKIM TXT records need the same treatment.
3. The first full sync (no downtime yet)
Now copy everything while both servers are live. Nothing is cut over; this pass just moves the bulk of the data so that the window itself only has to move a small delta.
For cPanel-to-cPanel, the transfer tool handles account structure, DNS zones, mail and
databases correctly and I'd always prefer it to hand-rolled rsync:
# Pull a single account from the source (run on the DESTINATION)
/scripts/pkgacct --skiphomedir username # on source, if staging manually
# Preferred: WHM Transfer Tool, scriptable via API
whmapi1 create_transfer_session \
transfer_threads=2 restore_threads=2 \
sessionid_prefix=migration_batch1
For non-panel servers, or for the delta pass, plain rsync is the tool. The flags
matter more than people think:
rsync -aHAX --numeric-ids --delete \
--exclude='.cagefs/' --exclude='tmp/' \
--info=progress2 \
-e "ssh -o StrictHostKeyChecking=no" \
root@source:/home/username/ /home/username/
-apreserves permissions, times, symlinks and ownership — the baseline.-Hpreserves hard links. Mail stores use them heavily; without this your disk usage can balloon.-A -Xkeep ACLs and extended attributes. SELinux contexts live in xattrs.--numeric-idsstops UID remapping surprises when the two servers disagree about user numbering.--deletemakes the destination a true mirror — essential on the delta pass, dangerous if you point it at the wrong path. Check twice.
4. Verify before you cut over
The destination is now serving the sites — it just isn't receiving traffic yet. This is the best possible time to find problems, because nothing is broken for anyone. Test by overriding DNS locally rather than changing it publicly.
# Force a single request at the new IP with the correct Host header
curl -sS -I --resolve example.com:443:203.0.113.45 https://example.com/
# Or add it to your local hosts file and browse normally
echo "203.0.113.45 example.com www.example.com" | sudo tee -a /etc/hosts
Walk a written checklist per site. Mine is roughly:
- Home page returns 200 and looks right (not a default page, not a database error).
- A dynamic page that hits the database renders — the CMS admin login is a good one.
- File upload works: permissions and ownership survived the copy.
- HTTPS is valid — certificate present, correct hostname, not self-signed.
- Cron jobs exist and their paths still resolve on the new box.
- Mail: send in, send out, and confirm the mailbox count matches the source.
- Redirects and rewrite rules still fire (
.htaccessarrived and Apache reads it).
5. The window: delta sync and cutover
Now the short part. With the bulk already copied, the actual window is usually minutes, not hours.
- Put the source into a quiet state. For a truly consistent database copy you want writes stopped — a short maintenance page on the source, or at minimum stopping cron.
- Run the delta sync. Same rsync command; it now only moves what changed since the full pass.
- Re-dump the databases. These change constantly and are the one thing you should always take fresh.
- Cut the DNS. Change the A and MX records to the new IP. With a 300-second TTL, the world follows within about five minutes.
- Leave the old server running. Do not power it off. It's your rollback, and it catches stragglers still resolving to the old IP.
# On source — consistent dump for InnoDB without locking everything
mysqldump --single-transaction --routines --triggers --events \
--databases user_wp | gzip > /tmp/user_wp.sql.gz
# Transfer and load on destination
scp /tmp/user_wp.sql.gz root@dest:/tmp/
ssh root@dest 'gunzip < /tmp/user_wp.sql.gz | mysql'
Configure mail forwarding on the old server. For the next few days, any mail that still arrives at the old IP should be relayed to the new one. Otherwise you'll silently lose messages from every sender whose resolver cached the old MX.
6. The days after
The migration isn't finished when DNS propagates. Keep the old server running for at least 72 hours — a week if the customer is nervous — and watch:
- Traffic on the old server. When its access logs go quiet, propagation is genuinely complete. That's your signal, not a propagation-checker website.
- Error logs on the new one. Missing PHP extensions and path problems show up here first, often before a customer notices.
- Mail queue. A growing queue on the new server means a reputation or configuration problem with the new IP.
- The new IP's reputation. A fresh IP has no sending history. Check it isn't already on a blacklist from a previous tenant.
Only when the old server has been silent for a full day do I raise TTLs back to normal and schedule decommissioning — and even then, I take a final image first.
What actually goes wrong
Five years of doing these, and it's almost always one of these:
- Hard-coded IPs and paths in application config. Search the codebase for the old IP before you cut over.
- The forgotten cron job that ran on the old server, kept running after cutover, and wrote to a database nobody was reading any more.
- SSL not reissued. Let's Encrypt validates over HTTP — if it runs before DNS points at the new box, it fails. Issue after cutover, or use DNS validation.
- The mailbox nobody mentioned. Almost always the one belonging to the person who signs off on the migration.
- PHP version drift. The new server's "default" is newer than the site expects, and a deprecation warning becomes a fatal error.
None of this is clever. It's just written down, done in order, and never skipped because the migration is "only a small one". The small ones are where people get casual, and casual is where data goes missing.