A move between hosts is almost never instant: some visitors keep hitting the old IP for hours while DNS caches expire. This article is about shrinking the write outage to a few minutes and not losing orders, sessions or mail along the way. Every command targets Ubuntu 24.04 LTS with nginx 1.24, PHP 8.3 and MySQL 8.0; MariaDB 10.11 differences are called out where they matter.
What “zero downtime” actually means
The site stays readable the whole time: pages open, the catalogue is visible, search works. What goes away for a few minutes is writing — forms, checkout, file uploads, sign-ups. That is the only honest way to guarantee that an order placed during the cutover does not disappear together with the old database.
The real danger here is not technical but logical: split state. If both servers accept writes for a while, you end up with two diverging data sets and you will be merging them by hand. So writes are disabled exactly once, on the old server, and enabled exactly once, on the new one.
What moves besides files and the database
Most nasty surprises come not from the files and not from the dump, but from everything that is moved manually and therefore forgotten. Make the list before you start copying.
| What moves | How | What is silently lost |
|---|---|---|
| Site files | rsync -aHAX --delete, root on the receiving side | file ownership, if the receiver cannot chown |
| MySQL database | mysqldump --routines --triggers --events | routines, triggers, events, charset and collation |
| Sessions, cache, queues | a copy of Redis dump.rdb | every user logged out, queued jobs gone |
| Let's Encrypt certificates | rsync /etc/letsencrypt/ as root | live/ symlinks into archive/ and the renewal timer |
| nginx configs | rsync /etc/nginx/ as root | a PHP-FPM socket path from the old PHP version — that is a 502 |
| Cron and systemd timers | manually only | jobs in /etc/cron.d and other users' crontabs |
| DKIM keys | a copy of your MTA's key directory | mail signing; the selector TXT record needs no change |
| SPF | a DNS edit made in advance | outgoing mail from the new IP lands in spam |
Sessions, cache and queues
On a typical site sessions and cache live in Redis or Memcached rather than in files or MySQL. Neither an rsync of the site directory nor mysqldump moves them, and after the cutover the site looks fine — except that every signed-in user is logged out, carts are empty, and unprocessed queue jobs vanished with the old server. Start by checking what is actually in use:
grep -E 'SESSION_DRIVER|CACHE_STORE|QUEUE_CONNECTION' .envIf it is Redis, move it separately — after stopping the workers, so that half a processed job does not end up in the snapshot:
# on the old server: take a snapshot and confirm it is fresh
redis-cli BGSAVE
sleep 5
redis-cli LASTSAVE
sudo rsync -a /var/lib/redis/dump.rdb root@203.0.113.10:/var/lib/redis/dump.rdb
# on the new server: restore ownership and restart, otherwise the snapshot is not loaded
sudo chown redis:redis /var/lib/redis/dump.rdb
sudo systemctl restart redis-serverCron and systemd timers
Periodic jobs on Ubuntu live in at least five places, and an rsync of the site directory copies none of them. Check all of them:
# every user's crontab, not just the current one and www-data
sudo ls -1 /var/spool/cron/crontabs/
for u in $(sudo ls -1 /var/spool/cron/crontabs/); do
echo "=== $u ==="
sudo crontab -u "$u" -l
done
# system jobs are easy to miss; packages drop theirs here too, certbot included
sudo cat /etc/crontab
sudo ls -l /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/
systemctl list-timers --allBuilding the new server as a mirror
Certificates and nginx configs
The /etc/letsencrypt directory is copied by a separate command and strictly as root. Three reasons. The archive and keys subdirectories are 0700 root:root — an unprivileged user gets “Permission denied” on every file and exit code 23, while live/ is still created and the error is easy to scroll past. The files in live/example.com/ are relative symlinks into ../../archive/example.com/: copying only live/, or copying with -L, leaves you with either broken links or files without history, and certbot renew breaks later.
# as root on the old server; -a preserves the live/ symlinks into archive/, -L must NOT be used
sudo rsync -aHAX --numeric-ids /etc/letsencrypt/ root@203.0.113.10:/etc/letsencrypt/
sudo rsync -aHAX --numeric-ids /etc/nginx/ root@203.0.113.10:/etc/nginx/The certbot package has to be installed on the new server separately: copying /etc/letsencrypt does not create certbot.timer, and without it renewal never runs. While you are there, open /etc/letsencrypt/renewal/example.com.conf — it records the validation method from the old server (authenticator = nginx or webroot_path), which may not match reality on the new one.
The PHP-FPM socket
This is the most common cause of a 502 right after a move. The old server was almost certainly on PHP 8.1 or 8.2, and the copied config says fastcgi_pass unix:/run/php/php8.1-fpm.sock;. On Ubuntu 24.04 with PHP 8.3 that socket does not exist: the socket is /run/php/php8.3-fpm.sock, the unit is php8.3-fpm.service, the pool lives in /etc/php/8.3/fpm/pool.d/www.conf, and the log is /var/log/php8.3-fpm.log.
A classic mistake: seeing
nginx -tpass and treating the config as working. The socket path is not checked during a config test —nginx -treports “syntax is ok” while the site returns 502 on every PHP request.
grep -rn 'fastcgi_pass' /etc/nginx/
ls -l /run/php/
systemctl status php8.3-fpm --no-pager
sudo nginx -t && sudo systemctl reload nginxBuild the nginx -t && systemctl reload nginx habit right away: several config edits follow below, and each one takes effect only after nginx reloads.
PHP extensions and settings
Compare extension sets for the SAPI the site actually runs under. What is in PATH is the CLI, while the site runs under FPM, and their sets differ by design: /etc/php/8.3/cli/conf.d and /etc/php/8.3/fpm/conf.d hold different symlinks, and opcache is off in CLI by default. Compare the CLI and you get a clean diff plus false confidence — then the site dies on an imagick or intl that FPM does not have.
# compare the FPM SAPI, not the CLI: their extension sets differ
ssh deploy@198.51.100.20 'php-fpm8.2 -m' | sort > /tmp/old-modules.txt # the old server's version
php-fpm8.3 -m | sort > /tmp/new-modules.txt
diff /tmp/old-modules.txt /tmp/new-modules.txtThe second most common source of “but it worked on the old one” is drifted ini values. Check them on both servers, then look separately at what the FPM pool overrides:
for h in 198.51.100.20 203.0.113.10; do
echo "=== $h ==="
ssh deploy@$h 'php -i | grep -E "^(memory_limit|upload_max_filesize|post_max_size|max_execution_time|date.timezone) "'
done
grep -E 'php_admin_value|php_value' /etc/php/8.3/fpm/pool.d/www.confA week ahead: mail, SPF and DKIM keys
Checking mail after the move is too late: between the A record change and the SPF edit, all outgoing mail goes to spam or bounces with 550 SPF check failed. The SPF record has a TTL of its own, and it needs lowering in advance too.
Add the new IP to SPF without removing the old one and keep both for the transition: v=spf1 ip4:198.51.100.20 ip4:203.0.113.10 ~all. The old IP can go a week after the move. DKIM does not depend on the IP at all: it is a private key in the MTA config plus a public key in the selector's TXT record. Move the private keys from the old server (/etc/opendkim/keys/ or your MTA's key directory) — the TXT record stays as it is. After the cutover, check both records:
dig example.com TXT +short | grep spf1
dig mail._domainkey.example.com TXT +shortA day ahead: TTL and the full record list
TTL is how long resolvers keep an answer cached. Nothing learns about the new value until the old one expires, so the TTL must be lowered at least one old TTL before the move — in practice, a day ahead. A working value during the move is 300 seconds; you raise it back afterwards.
A classic mistake: lowering the TTL an hour before the cutover. If it used to be 86400, some resolvers will not see the new value for another day, and until then they hold the old IP for exactly as long as the answer they already handed out says.
Look at every record you will have to switch, not just A. If the zone has an AAAA — and IPv6 is on by default at most modern hosts — IPv6 clients will keep reaching the old server indefinitely. The same goes for www: it may be a CNAME, in which case editing the apex A record does nothing for it.
dig example.com A +noall +answer
dig example.com AAAA +noall +answer
dig www.example.com ANY +noall +answer # A / AAAA / CNAME
dig example.com TXT +short | grep spf1An ANY query is more reliable when aimed at the zone's authoritative server: some public resolvers answer it per RFC 8482 and will not show the full record list.
To check whether the new TTL has propagated, name the resolver explicitly. Without an @ address, dig goes to /etc/resolv.conf, which on Ubuntu 24.04 points at the systemd-resolved stub 127.0.0.53, backed by several upstreams with independent caches: two consecutive queries may land on different upstreams and you will never see the countdown.
# pin the resolver explicitly, otherwise two queries may hit different upstreams
for r in 8.8.8.8 1.1.1.1 9.9.9.9; do
echo -n "$r: "; dig @$r example.com A +noall +answer
done
sleep 30
for r in 8.8.8.8 1.1.1.1 9.9.9.9; do
echo -n "$r: "; dig @$r example.com A +noall +answer
doneKeep in mind that a TTL of 300 is a guideline for most clients, not a guarantee for all. Plenty of public and ISP resolvers raise small TTLs to their own floor (often 30-60 seconds, and up to an hour on some corporate ones), and browsers keep a DNS cache of their own on top of the system's. Plan for a handful of clients arriving at the old IP hours later — which is exactly why the old server ends up as a proxy instead of being switched off.
The first file sync
Files are copied in full ahead of time so that only a short catch-up pass is left at cutover. Define the exclusion set once in a variable and use it in every pass: in rsync, --exclude not only skips files during transfer, it also protects them on the receiving side from --delete. Drop the exclusions in the later passes and you will push a multi-gigabyte cache and logs onto the new server, while --delete wipes whatever the new server generated during testing.
# one exclusion set for every pass
RSYNC_OPTS=(-aHAX --numeric-ids --delete
--exclude 'storage/framework/cache/'
--exclude 'var/cache/'
--exclude '*.log')
# the trailing slash on the source is mandatory: we copy the contents, not the directory itself
time rsync "${RSYNC_OPTS[@]}" --info=progress2 \
--rsync-path='sudo rsync' \
/var/www/example.com/ deploy@203.0.113.10:/var/www/example.com/The --numeric-ids flag preserves numeric UIDs and GIDs so permissions do not drift when users were created in a different order. But preserving ownership requires the right to chown on the receiving side: an unprivileged deploy simply cannot assign someone else's UID, rsync silently ignores it, and every file ends up as deploy:deploy — after which www-data cannot write to storage/, var/cache/ or public/uploads/. Hence --rsync-path='sudo rsync', which needs a sudoers line on the new server:
deploy ALL=(root) NOPASSWD: /usr/bin/rsyncIf you would rather not grant that, be honest and do not transfer ownership at all: add --no-owner --no-group, drop --numeric-ids, and set the owner once on the new server.
sudo chown -R www-data:www-data /var/www/example.comTesting the new server before the cutover
To know for certain which server answered, add a debug header on the new one. Where you put it matters: add_header is inherited into a location only if that location has no add_header of its own. Any real PHP site config has its own headers there, so the server-level X-Origin-Host is dropped — you will not find it in the response and will conclude you reached the old server when you reached the new one.
location ~ \.php$ {
add_header X-Origin-Host "new" always; # required here: inheritance from server is cancelled
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}Next, open the new server under the real domain without touching DNS. On the command line, --resolve is the convenient option: it needs no edit to a system file and leaves nothing behind.
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/ | grep -i x-origin-hostFor browser testing you edit /etc/hosts, and you must enter both addresses: if the domain has an AAAA record, getaddrinfo prefers the IPv6 answer from DNS over a lone IPv4 line in hosts, and you would be testing the old server.
203.0.113.10 example.com www.example.com
2001:db8::10 example.com www.example.com# macOS: flush the local DNS cache after editing hosts
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponderThis covers everything your browser initiates: admin login, file uploads, page rendering, database work. What hosts cannot test is everything that arrives from outside, because it follows DNS — that is, it goes to the old server:
payment provider webhooks — an end-to-end payment scenario simply cannot be tested this way, only the redirect to the payment form can;
OAuth callbacks and any responses from external services;
incoming mail — it follows MX records, and
/etc/hostshas no effect on it whatsoever;outgoing mail leaves from the new IP, which may not be in SPF yet, so the test gives a false negative.
Test those scenarios on a staging domain pointed at the new IP, in the provider's sandbox, or immediately after the cutover.
Read-only mode
The read-only flag must live outside the directory that rsync synchronises with --delete. Otherwise the catch-up pass carries the flag from the old server to the new one and the new server boots in read-only mode. The reverse case is worse: if anyone runs rsync from the old server again after the cutover, the flag returns to the live server and writes are disabled without a single line in any log.
<?php
// public/index.php, before the framework is loaded
// the path is outside /var/www — otherwise the flag rides along with rsync to the new server
$readOnly = is_file('/etc/example.com/readonly.flag');
if ($readOnly && ! in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD', 'OPTIONS'], true)) {
http_response_code(503);
header('Retry-After: 600');
exit('The site is temporarily read-only. Please try again in 10 minutes.');
}If you would rather not move the path, the minimum is to add --exclude 'storage/readonly.flag' to every rsync pass and write it down in the runbook.
The same mode can be enabled at the nginx level. The if directive is allowed only in server and location context: in http context nginx refuses to start with "if" directive is not allowed here. And do leave a path open for webhooks — many payment providers retry a 503 only a couple of times, some not at all, so you would simply lose every payment confirmation for the whole window.
server {
# ...
add_header Retry-After 600 always;
# do not cut off OPTIONS, or the CORS preflight on the frontend breaks
if ($request_method !~ ^(GET|HEAD|OPTIONS)$) {
return 503;
}
# webhooks must keep coming through — location wins over if in server context
location = /webhooks/payment {
try_files $uri /index.php$is_args$args;
}
}Moving the database
First read the charset and collation off the live database — you will need them when creating the database on the new server:
SELECT @@character_set_database, @@collation_database;Dump as a dedicated user. On MySQL 8.0.21 and newer, mysqldump --single-transaction takes LOCK INSTANCE FOR BACKUP, which requires the BACKUP_ADMIN privilege: a user with nothing but SELECT gets Access denied at the exact moment of the cutover — the worst possible place for that surprise.
GRANT SELECT, SHOW VIEW, TRIGGER, EVENT, PROCESS, RELOAD ON *.* TO 'backup'@'localhost';
GRANT BACKUP_ADMIN ON *.* TO 'backup'@'localhost'; -- MySQL 8.0, not needed on MariaDBThe --single-transaction flag takes a consistent InnoDB snapshot inside one transaction without blocking INSERT and UPDATE. But “does not block writes” holds for DML only: the dump holds metadata locks on every table, so any ALTER TABLE started during the dump queues up, and every subsequent query against that table queues behind it. In plain terms, deploying a migration alongside the dump takes the site down completely.
mysqldump --single-transaction --quick --routines --triggers --events \
--set-gtid-purged=OFF --default-character-set=utf8mb4 \
-u backup -p shop_db > /root/shop_db.sqlA word on the flags people forget. Without --routines --triggers --events the stored routines and events go nowhere, and you will not notice until the first event fails to fire. Without --default-character-set=utf8mb4 older builds default to latin1 and the dump comes out mojibaked. And --set-gtid-purged=OFF is there because on MySQL 8 with GTIDs enabled the dump contains SET @@GLOBAL.GTID_PURGED by default, and the import fails with @@GLOBAL.GTID_PURGED can only be set when @@GLOBAL.GTID_EXECUTED is empty.
At import time, remember that mysqldump db_name without --databases emits neither CREATE DATABASE nor USE. On a clean server the import fails with ERROR 1049 (42000): Unknown database 'shop_db'. Create the database yourself, and do it with the same collation the old server used: the MySQL 8.0 default on Ubuntu 24.04 is utf8mb4_0900_ai_ci, while the old server almost certainly had utf8mb4_unicode_ci, and the mismatch surfaces as Illegal mix of collations on the first JOIN that compares strings.
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS shop_db \
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql --default-character-set=utf8mb4 -u root -p shop_db < /root/shop_db.sqlYou can skip the intermediate file, but then watch the exit code: by default a pipeline returns the status of the last command, that is ssh, so a mysqldump failure mid-stream gives you a truncated dump imported “successfully”. Credentials on the receiving side must be explicit too, otherwise mysql runs as deploy and at best gets Access denied — at worst, under socket authentication, it lands in somebody else's database.
set -o pipefail
mysqldump --single-transaction --quick --routines --triggers --events \
--set-gtid-purged=OFF --default-character-set=utf8mb4 \
-u backup -p shop_db \
| ssh deploy@203.0.113.10 \
'mysql --defaults-file=/home/deploy/.my.cnf --default-character-set=utf8mb4 shop_db'
echo "exit=$?" # without pipefail this would always be 0The /home/deploy/.my.cnf file needs a [client] section with the login and password, and mode 600.
Cutover checklist
Announce the window and enable read-only mode on the old server.
Stop queue workers and cron on the old server, wait for running jobs to finish.
Take the final database dump, transfer it and import it on the new server; refresh the Redis snapshot there as well.
Run the catch-up rsync — dry run first, then the real one.
Verify the new server through
--resolve: theX-Origin-Hostheader, admin login, page reads.Change the A and AAAA records — for the apex and for
wwwif it is not a CNAME. If there is no AAAA on the new server, delete it in advance, one old TTL before the cutover.Clear the read-only flag on the new server and start cron and the workers there.
Turn the old server into a transparent proxy to the new IP.
The catch-up pass runs with the same exclusion set as the first one. The dry run is there to show exactly what changed — with the exclusions dropped it prints hundreds of lines of cache and logs, and head hides the real changes.
# dry run: change nothing, just look
rsync "${RSYNC_OPTS[@]}" --dry-run --itemize-changes --rsync-path='sudo rsync' \
/var/www/example.com/ deploy@203.0.113.10:/var/www/example.com/ | head -50
# the catch-up pass itself
time rsync "${RSYNC_OPTS[@]}" --rsync-path='sudo rsync' \
/var/www/example.com/ deploy@203.0.113.10:/var/www/example.com/The old server as a proxy
Leaving the old server “read-only for a week or two” is a bad idea. All that time, visitors with the old IP cached see a database frozen at dump time: stale prices, missing new products, other people's sessions and “vanished” orders they just placed. A crawler arriving at the old IP collects 503s on every non-GET request plus outdated content.
The only correct role for the old server after the cutover is a transparent proxy to the new IP. Late traffic is then served real data, and writes can be enabled right after the import.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass https://203.0.113.10;
proxy_ssl_server_name on; # without it no SNI is sent and the new server returns its default vhost
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Without proxy_ssl_server_name on; the proxy silently fails to work the way you expect: nginx sends no SNI, the new server answers with the default server's certificate, and you get a 502. The listen 443 ssl http2; form is correct here: the standalone http2 directive only arrived in nginx 1.25.1, and Ubuntu 24.04 ships nginx 1.24.
How long the window really is
What you have to add up is not “how long the dump takes” but every step, because they do not overlap. The TTL only starts ticking once the record is changed — that is, after the dump and the rsync, not in parallel with them.
An example calculation for a 2 GB database: the final dump and import take about 5 minutes, the catch-up rsync about 40 seconds, the TTL is 300 seconds. For a visitor whose resolver has already picked up the new IP, writing is unavailable for roughly 6 minutes; for one still holding the old answer in cache, up to 11 minutes. The proxy on the old server shrinks the second number to the first. This is an example calculation, not a measurement: you will get your own numbers from a rehearsal, and an import that rebuilds InnoDB indexes usually takes longer than the dump itself.
As long as the read-only flag is still in place, rollback is free: you just point the DNS records back at the old IP. Once the new server starts accepting writes, rolling back means losing everything written since — so the “commit or roll back” decision is made before step 7 of the checklist.
After the cutover
Check in order: does writing work on the new server, did cron and the workers start, is mail going out, is certificate renewal armed. Confirm separately that payment provider webhooks are arriving — they are the first sign that, from the outside, the domain now points where it should.
After that, watch traffic on the old server. Observation runs for a week or two while logrotate rotates nginx logs daily, and there is a catch: tail -f holds an open file descriptor, not a name. After rotation it keeps following the renamed access.log.1, which nginx no longer writes to; the output freezes, and you conclude “no traffic” while real visitors are still there.
# -F, not -f: reopens the file by name after logrotate rotates it
sudo tail -F /var/log/nginx/access.log
# more reliable — count requests per day across all rotations, compressed ones included
sudo zcat -f /var/log/nginx/access.log* | awk '{print $4}' | cut -d: -f1 | sort | uniq -cOnce the per-day count stays near zero, the old server can be shut down. By then, raise the TTL back to its previous value, drop the old IP from SPF, and make sure backups are being taken from the new server.







