Why Most Server Migrations Go Wrong
Server migrations are one of those tasks that look straightforward on paper. Move files, move databases, update DNS. Done. But in practice, they are responsible for some of the most painful outages in hosting history — hours of downtime, lost email, broken SSL certificates, scrambled databases, and customers who never come back.
The failures are almost always the same. Someone skips the DNS TTL step. A database gets corrupted during transfer. Email bounces for six hours because MX records pointed nowhere. A payment gateway breaks because a hardcoded IP stopped working. A site returns a 500 because the new server runs PHP 8.2 but the code was written for 7.4.
This guide exists to eliminate every one of those failure modes. It is a battle-tested, step-by-step checklist for moving sites between servers with zero unplanned downtime. Whether you are migrating one site or fifty, moving from cPanel to Panelica, or switching data centers, the same principles apply.
The #1 rule of server migration: plan everything before you start. A migration that takes two hours to execute should take two days to plan.
Pre-Migration Assessment
Before touching a single file, you need a complete picture of what you are moving. Surprises during migration are expensive. Surprises at 2 AM are catastrophic.
Complete Inventory
Run through this inventory on your source server and document everything:
- Domains and subdomains — List every domain, subdomain, and addon domain. Check for wildcard subdomains.
- Databases — Name, size, engine (MySQL/PostgreSQL), character set, collation. Note which site uses which database.
- Email accounts — All mailboxes, forwarders, autoresponders. Estimate mailbox sizes with
du -sh /var/mail/*. - SSL certificates — Expiry dates, whether they are Let's Encrypt or commercial. Note wildcard certs.
- Cron jobs — List all crontabs for all users. Note absolute paths that will break on the new server.
- DNS records — Export full zone files for every domain. Do not rely on memory.
- FTP/SFTP accounts — Users, paths, permissions.
- SSH keys and users — Authorized keys, sudo access, custom users.
Software Audit
Document the exact software stack on the source server. Mismatched versions are the #1 cause of post-migration breakage:
- PHP versions — Run
php -vand check per-domain PHP version settings. List every extension withphp -m. - MySQL/PostgreSQL version —
mysql --versionorpsql --version. A dump from MySQL 8 may not restore cleanly into MySQL 5.7. - Web server — Nginx version, Apache version. Check for custom modules.
- Custom software — Node.js, Python, Ruby versions. Check for system-level dependencies.
- Compiled PHP extensions — Imagick, GD, Redis, Memcached, APCu. These often require manual installation.
Resource Check
Know what you are moving before you move it:
# Total disk usage
df -h
# Per-site usage
du -sh /var/www/*
# Database sizes
mysql -e "SELECT table_schema AS 'Database', ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema;"
# Email mailbox sizes
du -sh /home/*/mail/ 2>/dev/null || du -sh /var/mail/vhosts/*
Dependency Map
Draw a dependency map before migrating. Which sites share a database? Which use a shared Redis instance? Does your WordPress multisite depend on a specific domain being the primary? Are there API keys or webhooks tied to the old server's IP address?
Pay special attention to hardcoded IPs. Search your code:
grep -r "OLD_SERVER_IP" /var/www/ --include="*.php" --include="*.env" --include="*.conf"
The Zero-Downtime Migration Timeline
Zero-downtime migration is not magic. It is a matter of doing the right things at the right times. Here is the timeline that works:
Day -7: Prepare new server
Install all software (matching versions)
Configure web server, PHP, databases
Run test migrations of non-production sites
Day -3: Initial data sync (rsync, full copy)
This is the slow step — large sites take hours
Keep old server running, both servers live
Day -2: Second rsync pass (delta only — much faster)
Test sites on new server using /etc/hosts trick
Fix any issues discovered in testing
Day -1: Lower DNS TTL to 300 seconds (5 minutes)
This must happen 48 hours before cutover
If your TTL is 86400 (24h), lower it now
Day 0: Cutover day
Final rsync (delta — seconds to minutes)
Final database sync
Update DNS records to new IP
Monitor both servers
Keep old server on standby
Day +1: Verify all sites, email, cron jobs
Monitor error logs on new server
Keep old server running as fallback
Day +7: Restore DNS TTL to 3600+
Decommission old server
Archive old server configs
Setting Up the New Server
The new server must be production-ready before you move a single file. Rushing this step is how you end up with a broken migration.
Match Your Software Stack Exactly
Install the same PHP versions that your sites use. If any site runs PHP 7.4, install PHP 7.4 on the new server even if you plan to upgrade later. Migration day is not the day to also upgrade PHP.
Match your database versions. If migrating from MySQL 5.7 to MySQL 8, do that migration separately, after the server move is complete and stable.
Configure Email Before You Need It
Email is where migrations get quietly broken. Set up your mail server configuration, including SPF, DKIM, and DMARC records for the new server's IP, before you switch DNS. If email breaks during migration, customers notice immediately.
Generate DKIM keys on the new server. Add them to DNS as secondary records (alongside the old server's records) before the cutover so there is no gap in email authentication.
Pre-Issue SSL Certificates
For Let's Encrypt certificates, use the DNS challenge method to issue certs before switching:
# DNS challenge — works before new server is live
certbot certonly --manual --preferred-challenges dns -d example.com -d www.example.com
This lets you have valid SSL certificates on the new server before DNS points to it. HTTP challenges will fail because the ACME server will reach the old server during verification.
Test With /etc/hosts
Before touching DNS, verify every site works on the new server by overriding DNS locally:
# On your local machine, add to /etc/hosts:
NEW_SERVER_IP example.com www.example.com
NEW_SERVER_IP shop.example.com
# Then test in browser or with curl:
curl -sk https://example.com
Or with curl's --resolve flag without editing hosts:
curl --resolve "example.com:443:NEW_SERVER_IP" https://example.com -I
Data Transfer Methods
File Transfer with rsync
rsync is the right tool for file migration. It is fast, resumable, and only transfers changed files on subsequent runs:
# Initial full sync (run this first — may take hours for large sites)
rsync -avz --progress \
-e "ssh -p 22 -i /root/.ssh/migration_key" \
/var/www/ \
root@NEW_SERVER_IP:/var/www/
# Preserve permissions and ownership
rsync -avz --progress --numeric-ids \
-e "ssh -p 22" \
/var/www/ \
root@NEW_SERVER_IP:/var/www/
# Delta sync (run on cutover day — only transfers changes)
rsync -avz --delete \
-e "ssh -p 22" \
/var/www/ \
root@NEW_SERVER_IP:/var/www/
The --delete flag on the final sync removes files from the destination that were deleted on the source. Use it only on the cutover run, not on intermediate syncs.
Use --info=progress2 instead of --progress for large transfers. It shows overall progress rather than per-file progress, which is far more useful:
rsync -avz --info=progress2 -e "ssh -p 22" /var/www/ root@NEW_SERVER:/var/www/
Database Migration
MySQL:
# Dump all databases
mysqldump --all-databases --single-transaction --routines --triggers \
-u root -p > all_databases.sql
# Dump a specific database
mysqldump --single-transaction --routines --triggers \
-u root -p mysite_db > mysite_db.sql
# Restore on new server
mysql -u root -p < all_databases.sql
# Or single database:
mysql -u root -p mysite_db < mysite_db.sql
PostgreSQL:
# Dump all databases
pg_dumpall -U postgres > all_databases.sql
# Dump a specific database
pg_dump -U postgres -Fc mysite_db > mysite_db.dump
# Restore
psql -U postgres < all_databases.sql
# Or single database:
pg_restore -U postgres -d mysite_db mysite_db.dump
For zero-downtime database migration, consider streaming replication instead of dump/restore. PostgreSQL logical replication and MySQL replication both allow the new server to stay in sync with the old server in real time. You flip the connection string rather than copying a snapshot, which eliminates the database-sync window on cutover day.
Email Mailbox Migration
Email migration has its own timing requirements. The safest approach:
- Keep MX records pointing to the old server until DNS propagation is complete for A records.
- Sync mailboxes with imapsync before the cutover:
# Install imapsync
apt-get install imapsync
# Sync a single mailbox (IMAP to IMAP)
imapsync \
--host1 old-server.com --user1 [email protected] --password1 "oldpass" \
--host2 new-server.com --user2 [email protected] --password2 "newpass"
For Dovecot-to-Dovecot migrations, dsync is more efficient than imapsync as it uses Dovecot's native replication protocol.
Switch MX records only after verifying the new mail server is fully configured, tested, and reachable. Email in transit during the MX switch will be queued and delivered to whichever server receives it — have both servers running during the transition period.
DNS Migration Strategy
DNS propagation is the one thing in a migration you cannot control. You can reduce the blast radius, but you cannot eliminate it entirely. The strategy is to make the propagation window as short as possible and as predictable as possible.
TTL Reduction (48 Hours Before)
Lower your TTL to 300 seconds at least 48 hours before cutover. This is not optional — it is the difference between a 5-minute propagation window and a 24-hour one:
# Check current TTL
dig +nocmd example.com A +noall +answer
# Your DNS provider's control panel: set TTL to 300 (5 minutes)
# Wait 48 hours for the old TTL to expire from all resolvers worldwide
DNS Records to Update
| Record Type | What to Update | When |
|---|---|---|
| A / AAAA | Main domain and www to new server IP | Cutover day |
| MX | Mail server hostname (if email is moving) | After mail server verified |
| SPF | Add new server IP before removing old | Before MX switch |
| DKIM | Add new selector before removing old | Before MX switch |
| PTR (reverse DNS) | Request from your hosting provider | Before going live |
| Subdomains | All A records pointing to old server | Cutover day |
Monitor Propagation
# Check propagation from Google's resolver
dig @8.8.8.8 example.com A +short
# Check from Cloudflare's resolver
dig @1.1.1.1 example.com A +short
# Check MX propagation
dig @8.8.8.8 example.com MX +short
# Use online tools for global view
# https://dnschecker.org / https://whatsmydns.net
SSL Certificate Strategy
SSL is where migrations go wrong silently. A site that loads without a valid certificate looks broken to users even if everything else works.
Option A: Pre-Issue Certificates (Recommended)
Use Let's Encrypt DNS challenge to issue certificates on the new server before switching DNS. This is the cleanest approach because certificates are ready before traffic arrives:
# Install certbot on new server
apt-get install certbot
# DNS challenge (no web server needed)
certbot certonly --manual \
--preferred-challenges dns \
-d example.com \
-d www.example.com
Certbot will give you a TXT record to add to DNS. Add it, verify it is live, then confirm. The certificate is issued without any HTTP traffic to the new server.
Option B: Copy Existing Certificates
If certificates are not expiring soon, copy them directly:
# Copy Let's Encrypt certs to new server
rsync -avz /etc/letsencrypt/ root@NEW_SERVER:/etc/letsencrypt/
Be aware: Let's Encrypt certificates expire every 90 days. If you copy them, set up auto-renewal on the new server immediately.
Avoid HTTP Challenge During Migration
Never use HTTP-01 challenge while DNS is in transition. The ACME server will send the HTTP request to whichever server the DNS currently points to — which might be the old server. Use DNS challenge for all certificate operations during the migration window.
The Day 0 Cutover Procedure
This is the sequence of steps on migration day. Execute them in order. Do not improvise.
Step-by-Step Cutover
-
Announce maintenance (if applicable)
Post a maintenance notice. Even a 5-minute window feels less painful when expected. -
Enable maintenance mode on sites (optional)
For WordPress:wp maintenance-mode activate
For custom sites: return a 503 with Retry-After header. -
Final rsync — files
With delta sync, this typically completes in under 5 minutes for active sites.rsync -avz --delete --info=progress2 \ -e "ssh -p 22" \ /var/www/ root@NEW_SERVER:/var/www/ -
Final database sync
mysqldump --single-transaction -u root -p mysite_db | \ ssh root@NEW_SERVER "mysql -u root -p mysite_db" -
Update DNS records
Change A/AAAA records to the new server IP. With TTL at 300, new visitors will reach the new server within 5 minutes. -
Disable maintenance mode
Sites on the new server are now live. -
Monitor both servers
During propagation, some users will still hit the old server. Both servers must be running and serving correctly. -
Watch error logs in real time
# On new server tail -f /var/log/nginx/error.log tail -f /var/log/php8.2-fpm.log
Post-Migration Verification Checklist
Do not declare success until you have verified everything. Work through this checklist systematically:
DNS and Connectivity
- All domains resolve to new server IP:
dig @8.8.8.8 example.com A +short - www subdomain resolves correctly
- All subdomains pointing to new server
- Reverse DNS (PTR) configured
HTTPS and SSL
- All sites load over HTTPS without certificate warnings
- Certificate is valid and issued for correct domain
- No mixed content warnings (browser console)
- HTTP redirects to HTTPS correctly
- SSL Labs score is A or better: ssllabs.com/ssltest
Site Functionality
- Homepage loads correctly
- Contact forms submit and send email
- Payment flows work end-to-end (test with sandbox)
- User login and registration work
- File uploads work
- Admin panels accessible
- APIs return expected responses
- Sending email works from all domains
- Receiving email works on all mailboxes
- SPF check passes: mxtoolbox.com/spf
- DKIM signing works
- DMARC policy active
- Test delivery to Gmail, Outlook, and Yahoo
- No emails in bounce queue on old server
Infrastructure
- All cron jobs running on new server
- Backup system configured and tested
- Log rotation configured
- Monitoring/alerts configured for new server
- Firewall rules correct
- SSH access secured (password auth disabled, key-only)
Your Rollback Plan
A migration without a rollback plan is a gamble. Define your rollback procedure before starting, not during a crisis.
Keep the old server running for at least 7 days after migration. Do not cancel the old hosting until you are certain everything works. The cost of running two servers for a week is trivial compared to losing customer data or extended downtime.
Rollback Steps
- Identify the problem on new server
- Set DNS A records back to old server IP
- Wait for TTL to propagate (300s = 5 minutes)
- Verify old server is serving correctly
- Diagnose and fix the issue on new server
- Retry migration
Keep the database backup from your cutover point on local storage. If the new server's database was modified during the brief live window, you will need this snapshot to restore to a clean state before retrying.
Common Gotchas That Break Migrations
These are the issues that bite experienced sysadmins, not just beginners:
- Hardcoded IPs in application configs — Search all .env files, wp-config.php, and custom configs for the old server IP.
- PHP version mismatch — A site running PHP 7.4 on the old server will break on PHP 8.2 if the code uses deprecated functions. Match versions exactly, then upgrade separately.
- Missing PHP extensions — PHP modules like
imagick,gd,redis,soap,ldapare not installed by default. Usephp -m | grep -i extension_nameon both servers. - Database collation differences — utf8mb3 vs utf8mb4 can cause import failures. Check with
SHOW VARIABLES LIKE 'character_set%'. - File permissions after rsync — rsync preserves permissions but the numeric UID may differ on the new server. Web server user (www-data) must be able to read web files.
- Cron jobs with absolute paths — Cron entries like
/old/path/to/phpbreak silently. Review all crontabs after migration. - Firewall rules blocking legitimate traffic — New server may have different default firewall rules. Verify ports 80, 443, 25, 587, 993 are open as needed.
- CDN cache serving old content — If using Cloudflare or another CDN, purge the cache after DNS switch. Otherwise users may see cached pages that reference the old server.
- Email queue on old server — Emails in the Postfix queue on the old server when you switch MX will be delivered from the old IP to the new mailbox. Check
mailqon the old server and flush the queue after the switch. - session.save_path differences — PHP session files stored in a custom path may not be transferred. Users will be logged out. This is expected — document it in your migration notice.
- WordPress siteurl and home options — If moving a WordPress site to a temporary domain for testing, update
siteurlandhomeinwp_options. Revert before final cutover.
Panel-to-Panel Migration
Migrating between control panels adds a layer of complexity because the directory structure, configuration format, and user management differ between systems. Here is how to approach the most common scenarios.
cPanel to Panelica
cPanel stores sites in /home/username/public_html/. Panelica uses per-domain document roots. The migration tool handles this automatically, but understand what it does:
- Creates a system user for each cPanel account
- Maps
public_htmlto the primary domain's document root - Imports databases with preserved credentials (MySQL password hashes are transferred directly — no passwords needed)
- Imports email accounts and mailbox content
- Recreates SSL certificates
- Recreates cron jobs
Plesk to Panelica
Plesk uses /var/www/vhosts/domain.com/httpdocs/. The structure maps cleanly to Panelica's domain model. Key differences to watch for:
- Plesk uses its own PHP handler configuration — verify PHP versions are matched on the new server
- Plesk's SpamAssassin configuration does not transfer — reconfigure spam filtering on the new server
- Plesk's custom Nginx rules (in Additional nginx directives) need to be manually recreated in Panelica's custom config fields
DirectAdmin to Panelica
DirectAdmin's backup format includes full site archives. Panelica's migration tool imports these directly. The main gotcha is PHP configuration — DirectAdmin uses custom PHP selector configurations that need to be matched on the new server.
Using Panelica's Built-In Migration Tool
If you are migrating to a server running Panelica, the built-in migration tool handles the entire pipeline — file transfer, database import, email migration, SSL — through a guided interface rather than manual commands. It connects to the source server via SSH (or the External API for Panelica-to-Panelica migrations), discovers all sites, and migrates them with a single workflow.
The key advantage for panel-to-panel migrations is that the tool understands panel-specific directory structures and configuration formats. Instead of generic rsync and database dumps, it performs a structured migration that preserves per-user isolation, PHP version assignments, and database ownership.
Automation Scripts
These scripts handle the repetitive parts of migration. Save them to your toolkit.
Pre-Migration Inventory Script
#!/bin/bash
# pre_migration_inventory.sh
# Generates a complete inventory of sites and resources
echo "=== DISK USAGE ==="
df -h /
echo ""
echo "=== WEB ROOTS ==="
du -sh /var/www/*/ 2>/dev/null | sort -rh
echo ""
echo "=== DATABASES ==="
mysql -u root -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length) / 1024 / 1024, 1) AS 'size_mb' FROM information_schema.tables GROUP BY table_schema ORDER BY size_mb DESC;" 2>/dev/null
echo ""
echo "=== EMAIL MAILBOXES ==="
find /home /var/mail -name "*.Maildir" -o -name "cur" 2>/dev/null | head -20
echo ""
echo "=== CRON JOBS ==="
for user in $(cut -f1 -d: /etc/passwd); do
crontab -l -u "$user" 2>/dev/null && echo " (user: $user)"
done
echo ""
echo "=== SSL CERTIFICATES ==="
find /etc/letsencrypt/live /etc/ssl/certs -name "*.pem" 2>/dev/null | xargs -I{} openssl x509 -in {} -noout -subject -enddate 2>/dev/null
echo ""
echo "=== PHP VERSIONS ==="
php -v 2>/dev/null
php -m 2>/dev/null | grep -v "\[" | sort
DNS Verification Script
#!/bin/bash
# dns_verify.sh - Verify DNS propagation after cutover
# Usage: ./dns_verify.sh new-server-ip domain1.com domain2.com ...
NEW_IP="$1"
shift
DOMAINS=("$@")
echo "Checking DNS propagation to $NEW_IP"
echo "Checking resolvers: 8.8.8.8, 1.1.1.1, 9.9.9.9"
echo ""
for domain in "${DOMAINS[@]}"; do
echo "--- $domain ---"
for resolver in 8.8.8.8 1.1.1.1 9.9.9.9; do
result=$(dig @$resolver +short $domain A 2>/dev/null)
if [ "$result" = "$NEW_IP" ]; then
echo " $resolver: $result [OK]"
else
echo " $resolver: $result [PENDING - still old IP]"
fi
done
echo ""
done
Post-Migration Health Check
#!/bin/bash
# post_migration_check.sh
# Quick health check after migration
DOMAINS=("example.com" "shop.example.com" "blog.example.com")
echo "=== SSL CERTIFICATE CHECK ==="
for domain in "${DOMAINS[@]}"; do
expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
status=$(curl -sk -o /dev/null -w "%{http_code}" "https://$domain")
echo " $domain — HTTP $status — SSL expires: $expiry"
done
echo ""
echo "=== DISK USAGE ==="
df -h /
echo ""
echo "=== SERVICES RUNNING ==="
for service in nginx php8.2-fpm mysql postgresql postfix dovecot; do
systemctl is-active "$service" 2>/dev/null && echo " $service: running" || echo " $service: STOPPED"
done
echo ""
echo "=== RECENT ERRORS (last 50 lines) ==="
tail -n 50 /var/log/nginx/error.log 2>/dev/null | grep -c "error" && echo "nginx errors found"
tail -n 50 /var/log/mysql/error.log 2>/dev/null | grep -c "ERROR" && echo "mysql errors found"
Quick Reference
Migration Timeline
| Day | Action | Estimated Time |
|---|---|---|
| -7 | Set up new server, install software | 2–4 hours |
| -3 | Initial rsync (full copy) | 1–12 hours |
| -2 | Test sites with /etc/hosts trick | 2–4 hours |
| -1 | Lower DNS TTL to 300 | 5 minutes |
| 0 | Final sync + DNS switch | 15–60 minutes |
| +1 | Monitor and verify | Ongoing |
| +7 | Restore TTL, decommission old server | 30 minutes |
Essential Commands
| Task | Command |
|---|---|
| Check DNS TTL | dig +nocmd example.com A +noall +answer |
| Check propagation | dig @8.8.8.8 example.com A +short |
| Test site on new server | curl --resolve "example.com:443:NEW_IP" https://example.com -I |
| rsync delta sync | rsync -avz --delete --info=progress2 -e "ssh -p 22" /src/ user@new:/dst/ |
| Dump MySQL database | mysqldump --single-transaction -u root -p mydb > mydb.sql |
| Check SSL cert expiry | echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -dates |
| Check mail queue | mailq |
| Verify DKIM | dig TXT default._domainkey.example.com +short |
The Bottom Line
Server migrations fail when people treat them as an afternoon task. Done right, a migration is a week-long project where the actual cutover is the fastest and least stressful part. The work happens in planning, testing, and preparation — the cutover is just executing a plan you have already verified works.
Lower that TTL early. Test on the new server before touching DNS. Keep the old server running for a week. Have a rollback plan written down before you start.
If you are moving to a server running Panelica, the built-in migration wizard handles the infrastructure complexity — file transfer, database import, email migration, SSL — through a guided interface. But the DNS strategy, TTL management, and post-migration verification remain your responsibility. No tool automates good judgment.
Plan the migration. Execute the plan. Verify everything before declaring victory.