Introduction
I replaced a production e-commerce server running Amazon Linux 2 (PHP 8.2 / mod_php) with a new Amazon Linux 2023 instance (PHP 8.3 / php-fpm). The database is a managed service shared by both, so the only things to move were code, uploaded files, sessions, and templates edited through the admin panel.
The result: the maintenance page was up for about 6 minutes, and because I moved the login sessions too, customers who were logged in across the cutover just kept working. The thing that made it smooth was splitting the runbook into three scripts — pre / final / verify. Here's the structure and the traps.
The structure — pre / final / verify
pre: zero-downtime sync you can run as many times as you want
Days before the cutover, start syncing the data that keeps changing on the old server.
# old server → new server (run on the new one) for d in html/upload var/sessions app/PluginData app/template; do rsync -az --delete \ --rsync-path="sudo rsync" \ old-prod:/var/www/app/$d/ /var/www/app/$d/ done # don't sync .env — just verify there's no drift ssh -n old-prod 'md5sum /var/www/app/.env'; md5sum /var/www/app/.env The key is --rsync-path="sudo rsync". Session files are mode 600 and owned by the web server user, so your SSH login user can't read them. Running only the sending side of rsync under sudo solves it. In my case: 169MB of uploads and 909 session files. The first run takes minutes; every run after that takes seconds.
final: maintenance ON → one last sync → "switch DNS now"
Once maintenance starts, the script just does the same sync one more time.
- Maintenance page ON on the old server
- ON on the new server too (so either server shows the same thing during DNS propagation)
- Run the same rsync again (919 sessions at this point → logins survive the cutover)
- Regenerate caches on the new server
- curl 4 key pages with a bypass cookie and confirm 200s
- Print "switch DNS now" and exit
The DNS change itself was the one step automation wasn't allowed to touch, so a human does it in the console. The script is designed to do everything up to one step before the human's button.
verify: confirm the cutover, then maintenance OFF
# check the A record on 3 public resolvers for r in 8.8.8.8 1.1.1.1 9.9.9.9; do dig +short @$r example.com; done # confirm which server actually answers curl -so /dev/null -w '%{remote_ip}\n' https://example.com/ # confirm the cert renewal path works on the new server sudo certbot renew --dry-run When all three check out, maintenance OFF. The old server stays up with maintenance still ON for a few days — rollback becomes "revert DNS and delete a flag file," nothing more.
Measured timeline
- 03:11:33 maintenance ON
- 03:13 DNS propagation confirmed
- 03:17 maintenance OFF
Every 5xx in that window was the maintenance page's 503 (5 requests); zero 500s. Response times after the swap: 0.12–0.48s (old server: 0.17–0.43s).
Traps
ssh inside a heredoc eats stdin. The script was piped into bash -s, and an ssh host 'cmd' > file in the middle consumed stdin — which was the rest of the script — so the remaining steps silently vanished. Always use ssh -n for remote commands inside scripts.
rsync's exit 23 vs set -e. Partial attribute-copy failures exit with 23. Allow it with || [ $? -eq 23 ], or set -e stops your script right there.
Don't pass -X to an SELinux target. Sending extended attributes produces thousands of fremovexattr denial lines. Drop it unless you need it.
Summary
The heart of the cutover is splitting the sync in two and not deleting the old server. Move the heavy data during pre, and final takes seconds — the maintenance window becomes roughly equal to DNS propagation time. Move the sessions, and customers never notice the swap happened.
One last thing: updating the date wording on your maintenance page is part of the runbook. I once had an old "scheduled through October 22, 2026" message show up during a later maintenance window, and someone noticed.
