When you migrate a live site to a new server, two things go wrong almost every time: data gets lost in the gap between your last sync and the cutover, and traffic keeps hitting the old box for hours because DNS is still cached. I have eaten both. The fix is not a single big-bang copy at midnight. It is a staged cutover: build the new server to match production, lower the DNS TTL a day ahead, pre-sync files and database while the old site is still live, test the new box through a hosts-file override before you touch DNS, then freeze writes, sync only the final delta, flip DNS, and keep the old server warm so rollback is one record away. Do it in that order and the worst case is a few minutes of read-only, not a lost order table.
Why does migrating a live site lose data or cause downtime?
Both failures come from the same source: time passing between when you copy and when traffic actually moves. You rsync the files and dump the database at 22:00, spend an hour configuring Nginx and TLS, flip DNS at 23:00, and every order, comment, and uploaded file created in that hour is sitting only on the old server. Worse, the old server is still receiving writes after you flip DNS, because resolvers cached the old A record with whatever TTL you had set, often 3600 seconds or more. So now you have two live databases diverging, and no clean way to merge them.
The staged approach kills both problems. You sync the bulk of the data ahead of time with the site fully live, so the final delta is tiny. You drop the TTL the day before so the cutover propagates in seconds rather than hours. And you put the site into a brief read-only or maintenance window for the final delta sync, so nothing is written anywhere during the one moment the two servers could diverge.
How do I prepare the new server before touching DNS?
Provision the new box and make it a faithful copy of production before you move a single byte of real data. Same OS major version (do not jump Ubuntu 22.04 to 24.04 mid-migration unless you have tested the app on it), same PHP/Node/Python runtime version, same web server, same extensions. A migration is the wrong time to also upgrade PHP 8.1 to 8.3; change one variable at a time. If you are unsure your new server matches, this is exactly what a staging environment that mirrors production is for. Validate the build there first.
Lower the DNS TTL at least 24 hours ahead. Whatever TTL is live now is how long stale resolvers will keep sending traffic to the old server after you cut over. Set it to 60 or 300 seconds the day before, let the old high TTL expire from caches, and the actual cutover will propagate almost immediately. Set it in Route 53 (or your DNS provider) on the A/AAAA records you will flip:
# Lower the TTL a day before cutover so caches expire fast.
aws route53 change-resource-record-sets \
--hosted-zone-id Z0123456789ABCDEFGHIJ \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "example.com.",
"Type": "A",
"TTL": 60,
"ResourceRecords": [{ "Value": "203.0.113.10" }]
}
}]
}'
# Confirm it propagated (query an authoritative NS, not your local cache):
dig +short example.com A @ns-1234.awsdns-12.orgBefore you go further, take a snapshot of the old server. If anything goes wrong you want a known-good restore point, not just the hope that the box is still healthy. Tag it clearly with the date. If you have not set this up yet, my notes on automating EC2 snapshots and backups cover doing it on a schedule so you are never one bad command away from data loss.
How do I sync the files and database to the new server?
Do the bulk sync with the site fully live. It does not need to be consistent yet; you are just moving the heavy stuff so the final delta is small. rsync is the right tool because the second run only transfers what changed. Run it from the new server pulling from the old, over SSH:
# Initial bulk sync (run from the NEW server, pulling from the old).
# -a archive (perms, times, symlinks), -z compress, -H preserve hardlinks,
# --delete makes the target match the source exactly,
# --partial resumes interrupted transfers.
rsync -azH --delete --partial \
--exclude '.git' \
--exclude 'storage/framework/cache/*' \
--exclude 'node_modules' \
-e 'ssh -i ~/.ssh/migrate_key' \
deploy@203.0.113.10:/var/www/example.com/ \
/var/www/example.com/
# Run it a second time a few minutes later: only the delta moves,
# and it tells you how stable the file set is before cutover.For the database, dump it on the old server and restore on the new one. Use --single-transaction so InnoDB tables are dumped from a consistent snapshot without locking writes; the whole point is that you can run this while the site is live without taking it down:
# On the OLD server: consistent dump without locking the whole DB.
# --single-transaction: consistent snapshot for InnoDB (no table locks).
# --routines --triggers --events: don't silently drop stored programs.
# --default-character-set=utf8mb4: avoid mojibake on emoji/multibyte data.
# Credentials come from ~/.my.cnf (a [mysqldump] section with user/password,
# chmod 600) so the password never lands in your shell history or ps output.
mysqldump --single-transaction --quick \
--routines --triggers --events \
--default-character-set=utf8mb4 \
appdb | gzip > /tmp/appdb-$(date +%F).sql.gz
# Copy it to the new server:
scp -i ~/.ssh/migrate_key /tmp/appdb-$(date +%F).sql.gz \
deploy@198.51.100.20:/tmp/
# On the NEW server: create the DB, then restore.
mysql -u root -p -e 'CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'
gunzip < /tmp/appdb-$(date +%F).sql.gz | mysql -u root -p appdbNow test the new server before any DNS change. The cleanest way is to point your own machine at the new IP with a hosts-file entry, so only you hit the new box while the world still hits the old one. On your laptop, add a line to the hosts file and browse the real domain against the new server:
# Force YOUR machine to resolve the domain to the NEW server.
# Everyone else still hits the old box. Remove this line after testing.
198.51.100.20 example.com www.example.comWith that in place, click through the real flows against the new server using the real domain name: log in, submit a form, upload a file, hit a page that reads from the database. A temporary subdomain like new.example.com pointed at the new IP works too, but the hosts override is better because it exercises the exact hostname your app and cookies expect.
What about TLS, queued jobs, and sessions?
Three things bite people right after cutover, so handle them before you flip DNS, not after.
- TLS must already be valid on the new box. Certbot's HTTP-01 challenge needs port 80 reachable on the domain, which it is not until DNS points at the new server, so issue the certificate ahead of time using the DNS-01 challenge (or AWS Certificate Manager if you front the site with a load balancer or CloudFront; note ACM certs for CloudFront must live in us-east-1, while ALB certs must be in the ALB's own region). Verify with: curl -vI https://example.com --resolve example.com:443:198.51.100.20 and confirm the chain and expiry before cutover.
- Queued jobs and cron: stop the workers and disable cron on the OLD server during the freeze, then start them on the NEW one only after cutover. Run both at once and you double-send emails or double-charge cards.
- Sessions: if sessions live in files on the old box, every logged-in user gets booted at cutover. Move sessions to the database or Redis before you migrate so they survive the move, or accept and announce the forced re-login.
- File-based caches and absolute paths: clear compiled caches and config caches on the new server, and grep your config for hardcoded old-server IPs or paths.
On the queue side specifically, drain in-flight work before the freeze: let the old workers finish their current jobs, then stop them, so nothing is half-processed when you cut over. Confirm the queue is actually empty before you proceed, not just that the workers have stopped accepting new jobs.
How do I do the actual cutover with a clean rollback?
This is the only step with real downtime, and it should last minutes. The sequence matters. Put the old site into maintenance mode or freeze writes (read-only is enough for most sites and far less disruptive than a full outage). With writes frozen, the database can no longer diverge, so now you sync only the final delta:
# 1. Freeze writes on the OLD server (maintenance page or read-only mode),
# and stop its queue workers + cron.
# 2. Final file delta (tiny now, because you pre-synced). Run on NEW server:
rsync -azH --delete --partial \
-e 'ssh -i ~/.ssh/migrate_key' \
deploy@203.0.113.10:/var/www/example.com/ \
/var/www/example.com/
# 3. Final DB delta: re-dump and restore (writes are frozen, so it's consistent).
# Credentials come from ~/.my.cnf on each box (chmod 600) -- never put the
# password on the command line; -pPASS would leak it into ps and history.
ssh -i ~/.ssh/migrate_key deploy@203.0.113.10 \
"mysqldump --single-transaction --quick --routines --triggers --events \
--default-character-set=utf8mb4 appdb | gzip" \
| gunzip | mysql appdb
# 4. Smoke-test the new box one more time via the /etc/hosts override.
# 5. Flip DNS to the new IP (TTL is already 60s, so this propagates fast).
aws route53 change-resource-record-sets --hosted-zone-id Z0123456789ABCDEFGHIJ \
--change-batch '{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{"Name":"example.com.","Type":"A","TTL":60,"ResourceRecords":[{"Value":"198.51.100.20"}]}}]}'Then watch. Tail the new server's access and error logs and confirm real traffic is landing and returning 200s, not 500s. Because the old TTL has already expired from caches, you will see traffic shift to the new box within a minute or two. Keep the old server running and untouched, with its database now read-only, as your rollback. If the new server falls over, rollback is flipping the single DNS record back to the old IP, and because you froze writes, the old database is still the authoritative copy with nothing lost.
The migration you can roll back in thirty seconds is the only one you should run against production. If rollback means restoring a backup, you have not planned a migration, you have planned a gamble.
Leave the old server warm for at least 24 to 48 hours after cutover, long enough that you are confident and that any lingering cached resolvers have moved on. Only then do you re-enable writes solely on the new server, restart its queue workers and cron, and finally decommission the old box. The whole reason this works is that you never let two servers accept writes at the same time, and you never let DNS surprise you. Build to match, lower the TTL early, pre-sync the bulk, freeze for the delta, flip, and keep the old box as a parachute. For the related problem of shipping code to that new server without an outage, see my notes on zero-downtime deployment for PHP.

