Moving a production site to a new server does not have to mean hours of downtime. The trick is to copy almost everything while the old server keeps serving traffic, keep the database in sync with replication, and then switch over in a short, rehearsed cutover. In this tutorial you will migrate a typical Nginx, PHP and MySQL site from an old server to a new Ubuntu 24.04 server using rsync, MySQL source-replica replication and a low-TTL DNS change, with a rollback path if something goes wrong.
Prerequisites
To follow this guide you need:
- The old server (called
oldbelow) running the site with Nginx, PHP-FPM and MySQL 8.0. - A new server (called
new) running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root sudo user. - The same software installed on
new:nginx,php-fpmwith the extensions your site uses, andmysql-server. Match the major versions where possible. - Access to your DNS provider to edit the A/AAAA records of
your_domain. - Network connectivity from
newtooldon TCP ports 22 and 3306. A private network between both servers is ideal.
Throughout the guide, replace old_server_ip, new_server_ip, your_domain and appdb (the database name) with your own values.
Step 1 - Lowering the DNS TTL
Resolvers cache your DNS records for the record's TTL. If the A record for your_domain has a TTL of 3600 seconds, some visitors keep reaching the old server for up to an hour after the change. Check the current TTL:
dig +noall +answer your_domain A
your_domain. 3600 IN A old_server_ip
In your DNS provider, lower the TTL of the A and AAAA records for your_domain (and www) to 300 seconds or less. Do this at least one full old TTL before the cutover, ideally 24 hours earlier, so that every cached copy with the long TTL has expired.
Step 2 - Copying files with an initial rsync
Copy the site files, Nginx configuration and TLS certificates while old is still live. Doing the bulk copy now means the final sync during cutover only transfers what changed.
To preserve ownership, rsync must run as root on both ends. On new you run it with sudo, which means SSH uses root's key. Create one on new:
sudo ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""
sudo cat /root/.ssh/id_ed25519.pub
On old, append that public key to ~/.ssh/authorized_keys of your_user. Then allow your_user to run rsync as root without a password by creating a sudoers drop-in file:
sudo visudo -f /etc/sudoers.d/rsync-migration
Add this single line and save:
your_user ALL=(root) NOPASSWD: /usr/bin/rsync
Back on new, copy the web root:
sudo rsync -aHAX --numeric-ids --info=progress2 \
-e ssh --rsync-path="sudo rsync" \
your_user@old_server_ip:/var/www/ /var/www/
The options preserve permissions, ownership, hard links, ACLs and extended attributes. Note the trailing slashes: they copy the contents of /var/www/ into /var/www/, not a nested www directory.
Copy the Nginx site definitions and the Let's Encrypt certificates so HTTPS works on new before DNS points to it:
sudo rsync -aHAX -e ssh --rsync-path="sudo rsync" \
your_user@old_server_ip:/etc/nginx/sites-available/ /etc/nginx/sites-available/
sudo rsync -aHAX -e ssh --rsync-path="sudo rsync" \
your_user@old_server_ip:/etc/letsencrypt/ /etc/letsencrypt/
Enable the site and test the configuration:
sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
If the site uses a PHP-FPM socket path that includes the version (for example php8.1-fpm.sock on the old server and php8.3-fpm.sock on Ubuntu 24.04), update the fastcgi_pass line in the site file.
Step 3 - Preparing the old server as a replication source
Instead of dumping the database during the cutover, you will make new a live replica of old. At cutover time the replica is already up to date.
MySQL 8.0 enables binary logging by default, which replication needs. On old, confirm it and check the server ID:
sudo mysql -e "SELECT @@log_bin, @@server_id, @@binlog_format;"
+-----------+-------------+-----------------+
| @@log_bin | @@server_id | @@binlog_format |
+-----------+-------------+-----------------+
| 1 | 1 | ROW |
+-----------+-------------+-----------------+
On Ubuntu, MySQL listens only on 127.0.0.1. Open the configuration file on old:
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
Change bind-address to the address new will connect to (the private IP if you have one):
bind-address = old_server_ip
Restart MySQL and allow port 3306 only from new:
sudo systemctl restart mysql
sudo ufw allow from new_server_ip to any port 3306 proto tcp
Create a replication user that can connect only from new. Replace your_strong_password with a long random password:
sudo mysql -e "CREATE USER 'repl'@'new_server_ip' IDENTIFIED BY 'your_strong_password'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'new_server_ip';"
Step 4 - Seeding the replica with a consistent dump
Take a dump of the application database on old. The --single-transaction option gives a consistent snapshot of InnoDB tables without locking the site, and --source-data=2 writes the matching binary log position into the dump as a comment:
sudo mysqldump --single-transaction --source-data=2 --routines --triggers --events \
--databases appdb | gzip > appdb.sql.gz
Look at the recorded position:
zcat appdb.sql.gz | grep -m1 "CHANGE REPLICATION SOURCE"
-- CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='binlog.000042', SOURCE_LOG_POS=157;
Write down the file name and position. Copy the dump to new:
scp appdb.sql.gz your_user@new_server_ip:~
On new, give MySQL a different server ID and restrict replication to the application database. Open the configuration file:
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
Add these lines to the [mysqld] section:
server-id = 2
replicate-do-db = appdb
Restart MySQL and import the dump:
sudo systemctl restart mysql
zcat ~/appdb.sql.gz | sudo mysql
Recreate the application's database user on new with the same name, password and grants it has on old, so the site can connect after the cutover. You can print the grants on old with sudo mysql -e "SHOW GRANTS FOR 'appuser'@'localhost';".
Step 5 - Starting replication
On new, point the replica at old using the file and position from the dump. SOURCE_SSL=1 encrypts the connection, which MySQL 8's default caching_sha2_password authentication requires over the network:
sudo mysql -e "CHANGE REPLICATION SOURCE TO SOURCE_HOST='old_server_ip', SOURCE_USER='repl', SOURCE_PASSWORD='your_strong_password', SOURCE_LOG_FILE='binlog.000042', SOURCE_LOG_POS=157, SOURCE_SSL=1; START REPLICA;"
Check the replica status:
sudo mysql -e "SHOW REPLICA STATUS\G" | grep -E "Running|Seconds_Behind|Last_.*Error"
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 0
Last_IO_Error:
Last_SQL_Error:
Both threads must say Yes. Leave replication running; from now on every write on old reaches new within a second or so.
Step 6 - Testing the new server before the cutover
Test the full site on new without touching DNS. curl --resolve sends the request to new while still using your real host name, so TLS and virtual hosts behave exactly as they will after the switch:
curl -sI --resolve your_domain:443:new_server_ip https://your_domain/
HTTP/2 200
server: nginx
content-type: text/html; charset=UTF-8
To browse the site in a desktop browser, temporarily add new_server_ip your_domain to your workstation's /etc/hosts file. Log in, load key pages and check the PHP and Nginx error logs on new. Avoid actions that write to the database here: new is still a replica and writes would break replication.
Write down the cutover steps below and, if possible, rehearse them once on a staging copy.
Step 7 - Performing the cutover
The cutover is the only moment with downtime, usually under a minute. Run the steps in order.
1. Stop writes on the old server. Stop PHP-FPM on old so no request can change the database or the files. Nginx keeps running and returns a 502 for dynamic pages, or you can switch it to a maintenance page:
sudo systemctl stop php8.1-fpm
Use the PHP-FPM service name of your old server.
2. Run the final file sync. From new, run the same rsync as in Step 2 with --delete, so files removed on old are also removed on new. It completes quickly because only changes are transferred:
sudo rsync -aHAX --numeric-ids --delete -e ssh --rsync-path="sudo rsync" \
your_user@old_server_ip:/var/www/ /var/www/
3. Confirm the replica has caught up. On old, read the current binary log position:
sudo mysql -e "SHOW MASTER STATUS;"
On new, check that Relay_Source_Log_File and Exec_Source_Log_Pos match that file and position:
sudo mysql -e "SHOW REPLICA STATUS\G" | grep -E "Relay_Source_Log_File|Exec_Source_Log_Pos"
4. Promote the new server. Stop replication and remove the replica configuration so new becomes an independent primary:
sudo mysql -e "STOP REPLICA; RESET REPLICA ALL;"
Remove the replicate-do-db line from /etc/mysql/mysql.conf.d/mysqld.cnf and restart MySQL. Keep server-id = 2.
5. Switch DNS. Change the A and AAAA records for your_domain to new_server_ip.
6. Forward stragglers. Some clients will reach old until their cache expires. Make old proxy every request to new so nothing is lost. In the HTTPS server block of the site configuration on old, remove all existing location blocks (including the \.php$ one, which would otherwise still match PHP requests) and add this single block:
location / {
proxy_pass https://new_server_ip;
proxy_ssl_server_name on;
proxy_ssl_name your_domain;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Test and reload Nginx on old:
sudo nginx -t && sudo systemctl reload nginx
Step 8 - Verifying the migration
Check that public DNS returns the new address:
dig +short your_domain A @1.1.1.1
new_server_ip
Compare table checksums on old, whose data has been frozen since you stopped PHP-FPM, and on new. Run the same command on both servers; the values must match for every table:
sudo mysql -e "CHECKSUM TABLE appdb.users, appdb.orders;"
Watch the access log on new and confirm real traffic is arriving and returning 200 responses:
sudo tail -f /var/log/nginx/access.log
After a few TTL periods, the access log on old should only show proxied requests from bots with stale caches. Once you are confident, restore the DNS TTL to its normal value.
Rollback plan
Until you decommission old, you can go back:
- Remove the proxy block on
oldand restore the original site configuration. - Start PHP-FPM on
oldagain. - Point the DNS records back to
old_server_ip.
Any data written on new after the cutover does not exist on old. If you roll back after real users have written data, export those rows from new first. For that reason, decide your rollback deadline in advance (for example 30 minutes after cutover) and keep old untouched for at least a week.
Troubleshooting
Replica_IO_Running: Connecting with an authentication error. Check that the repl user host matches the IP new connects from, that UFW on old allows port 3306 from it, and that SOURCE_SSL=1 is set.
Replica_SQL_Running: No with a duplicate key error. Writes reached new while it was a replica, or the dump and the position do not match. Reseed from a fresh dump (Step 4).
rsync reports "Permission denied (publickey)" or asks for a sudo password. Check that root's public key from new is in authorized_keys of your_user on old, and that the sudoers drop-in from Step 2 exists. Remove that drop-in once the migration is finished.
Conclusion
You migrated a live web application to a new server with only a short write freeze: files were pre-copied with rsync, the database was kept current with MySQL replication, and DNS was switched after its TTL had been lowered, with the old server forwarding late visitors. The same pattern works for PostgreSQL (with streaming replication) and for other stateful services. Next, consider putting a load balancer or floating IP in front of the application so future moves need no DNS change at all, and set up automated backups on the new server before retiring the old one.
