Moving a live application to a new server is mostly a synchronization problem: users keep uploading files and writing to the database while you copy data across. If you copy once and switch, everything written in between is lost. In this tutorial you will copy the bulk of the data ahead of time, keep files in sync with rsync and lsyncd, keep a MySQL database in sync with GTID replication, and finish with a short, controlled cutover on Ubuntu 24.04.
Prerequisites
To follow this guide you need:
- Two servers running Ubuntu 24.04 LTS: the current production server (called the source,
source_server_ip) and the new one (the destination,new_server_ip), for example a CubePath VPS. - A non-root user with
sudoprivileges on both servers. - Enough disk space on the destination for all application files and the database.
- MySQL 8.0 (the
mysql-serverpackage from Ubuntu 24.04) installed on both servers, if you want to replicate a database. - A DNS record for your application whose TTL you can lower before the cutover.
Throughout the guide, the web files live in /var/www/ and the database is called your_database. Replace these with your own paths and names.
Step 1 - Lowering the DNS TTL
Clients cache DNS answers for as long as the record's TTL says. If the TTL is one day, some visitors will keep hitting the old server for a day after you change the record. At least 24 hours before the cutover, lower the TTL of the application's A and AAAA records to 300 seconds in your DNS provider.
Check the value clients now receive:
dig +noall +answer your_domain A
your_domain. 300 IN A source_server_ip
The second column is the TTL in seconds.
Step 2 - Allowing root SSH access from the source to the destination
To preserve file ownership and permissions, rsync must write as root on the destination. The cleanest way is a dedicated SSH key that lets root on the source log in as root on the destination. Ubuntu's default PermitRootLogin prohibit-password allows key logins for root, so no SSH configuration change is needed.
On the source, create a key for root without a passphrase (it will be used by background processes):
sudo ssh-keygen -t ed25519 -N "" -f /root/.ssh/id_migration
Print the public key:
sudo cat /root/.ssh/id_migration.pub
On the destination, append that line to root's authorized_keys:
sudo mkdir -p /root/.ssh
sudo nano /root/.ssh/authorized_keys
Paste the key on a new line, save the file, then fix the permissions:
sudo chmod 700 /root/.ssh
sudo chmod 600 /root/.ssh/authorized_keys
Back on the source, test the connection. The first run asks you to accept the destination's host key:
sudo ssh -i /root/.ssh/id_migration root@new_server_ip hostname
The command should print the destination's hostname without asking for a password.
ImportantRemove this key from
/root/.ssh/authorized_keyson the destination once the migration is finished.
Step 3 - Running the initial file copy
The first copy moves everything and can take hours on large sites, so start it early while the old server keeps serving traffic. Run it on the source:
sudo rsync -aHAX --numeric-ids --info=progress2 \
-e "ssh -i /root/.ssh/id_migration" \
/var/www/ root@new_server_ip:/var/www/
The options do the following:
-apreserves permissions, timestamps, symlinks, owners and groups.-H,-Aand-Xpreserve hard links, ACLs and extended attributes.--numeric-idskeeps UIDs and GIDs as numbers, so files stay owned by the right user even if names map differently.- The trailing slash on
/var/www/copies the directory contents, not the directory itself.
Because of --numeric-ids, make sure the application user (for example www-data, UID 33 on Ubuntu) has the same UID on both servers. Compare with id www-data on each.
Run the same command a second time. Only files that changed since the first pass are transferred, so it finishes much faster. The duration of this second pass is a good estimate of how long the final sync will take during the cutover.
Step 4 - Keeping files in sync continuously with lsyncd
Instead of re-running rsync by hand, lsyncd watches the source directory with inotify and runs rsync a few seconds after files change. This keeps the destination seconds behind the source until the cutover.
Install lsyncd on the source:
sudo apt update
sudo apt install lsyncd
The Ubuntu package starts lsyncd with /etc/lsyncd/lsyncd.conf.lua, which does not exist yet. Create the directories for the configuration and logs:
sudo mkdir -p /etc/lsyncd /var/log/lsyncd
sudo nano /etc/lsyncd/lsyncd.conf.lua
Add the following configuration:
settings {
logfile = "/var/log/lsyncd/lsyncd.log",
statusFile = "/var/log/lsyncd/lsyncd.status",
}
sync {
default.rsync,
source = "/var/www/",
target = "root@new_server_ip:/var/www/",
delay = 5,
exclude = { "*.tmp", "cache/" },
rsync = {
archive = true,
compress = true,
rsh = "/usr/bin/ssh -i /root/.ssh/id_migration",
},
}
delay batches changes for five seconds before each rsync run, and exclude skips temporary and cache files you do not need on the new server. Adjust both to your application. default.rsync also deletes files on the destination that were deleted on the source.
Enable and start the service:
sudo systemctl enable --now lsyncd
Check that it is running and that the initial sync finished:
sudo systemctl status lsyncd
sudo tail -n 5 /var/log/lsyncd/lsyncd.log
... Normal: recursive startup rsync: /var/www/ -> root@new_server_ip:/var/www/
... Normal: Startup of /var/www/ -> root@new_server_ip:/var/www/ finished.
To confirm it works, create a file on the source and check that it appears on the destination a few seconds later:
sudo touch /var/www/lsyncd-test
ls -l /var/www/lsyncd-test
Run the second command on the destination, then delete the test file on the source.
NoteOn directories with hundreds of thousands of files, inotify can hit the default watch limit. If the log shows
Terminating since out of inotify watches, raise it withecho "fs.inotify.max_user_watches=1048576" | sudo tee /etc/sysctl.d/90-inotify.conffollowed bysudo sysctl --system, then restart lsyncd.
Step 5 - Configuring the source MySQL server for replication
Copying MySQL data files while the server is running produces a corrupt copy, and a single dump goes stale immediately. Replication solves both: the destination imports a consistent dump and then applies every change from the source's binary log until you stop it.
This guide uses GTID-based replication, where each transaction has a global ID, so the replica always knows exactly where to resume. Binary logging is already enabled by default in MySQL 8.0.
On the source, open the MySQL configuration:
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
In the [mysqld] section, set a unique server ID, enable GTIDs and listen on the network interface. Change the existing bind-address = 127.0.0.1 line instead of adding a second one:
[mysqld]
server-id = 1
gtid_mode = ON
enforce_gtid_consistency = ON
bind-address = 0.0.0.0
enforce_gtid_consistency rejects a few statement types that cannot be logged safely with GTIDs, such as CREATE TABLE ... SELECT in older applications. Most applications are not affected, but test this on a staging copy if you are unsure.
Restart MySQL and check the settings:
sudo systemctl restart mysql
sudo mysql -e "SELECT @@server_id, @@gtid_mode, @@log_bin;"
+-------------+-------------+-----------+
| @@server_id | @@gtid_mode | @@log_bin |
+-------------+-------------+-----------+
| 1 | ON | 1 |
+-------------+-------------+-----------+
Create a replication user that can only connect from the destination. Replace your_strong_password with a strong password:
sudo mysql -e "CREATE USER 'replicator'@'new_server_ip' IDENTIFIED BY 'your_strong_password'; GRANT REPLICATION SLAVE ON *.* TO 'replicator'@'new_server_ip';"
Allow MySQL traffic only from the destination in UFW:
sudo ufw allow from new_server_ip to any port 3306 proto tcp
Step 6 - Seeding the destination and starting replication
On the destination, apply the matching configuration with a different server ID. read_only prevents the application or an operator from writing to the replica by accident; the replication thread itself is not blocked by it:
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
server-id = 2
gtid_mode = ON
enforce_gtid_consistency = ON
read_only = ON
sudo systemctl restart mysql
On the source, create a consistent dump. --single-transaction takes a snapshot of InnoDB tables without locking them, and --set-gtid-purged=ON writes the GTID position into the dump so the replica knows where to start:
sudo mysqldump --databases your_database --single-transaction \
--routines --triggers --events --set-gtid-purged=ON \
| gzip > ~/your_database.sql.gz
Copy the dump to the destination:
scp ~/your_database.sql.gz your_user@new_server_ip:~
On the destination, clear any local GTID history (required before importing a dump that sets gtid_purged), then import:
sudo mysql -e "RESET MASTER;"
zcat ~/your_database.sql.gz | sudo mysql
Warning
RESET MASTERdeletes the destination's binary logs. Run it only on the new, empty server, never on the source.
Recreate the application's database user on the destination, since --databases does not dump accounts. Then point the replica at the source and start it. SOURCE_AUTO_POSITION=1 uses the GTIDs from the dump, and SOURCE_SSL=1 encrypts the connection with the certificates MySQL 8.0 generates automatically:
sudo mysql -e "CHANGE REPLICATION SOURCE TO SOURCE_HOST='source_server_ip', SOURCE_USER='replicator', SOURCE_PASSWORD='your_strong_password', SOURCE_AUTO_POSITION=1, SOURCE_SSL=1; START REPLICA;"
Check the replication status:
sudo mysql -e "SHOW REPLICA STATUS\G" | grep -E "Replica_IO_Running|Replica_SQL_Running:|Seconds_Behind_Source|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. Seconds_Behind_Source shows how far behind the replica is; it should drop to 0 once the replica has caught up with changes made during the dump.
Step 7 - Testing the new server before the cutover
With files and data in sync, test the application on the new server without touching public DNS. On your workstation, add a line to /etc/hosts (or C:\Windows\System32\drivers\etc\hosts on Windows) that points your domain at the new server:
new_server_ip your_domain
Browse the site and check pages that read from the database. Because the replica is read-only, anything that writes (logins, forms) is expected to fail at this stage. Remove the line when you are done.
Step 8 - Performing the cutover
The cutover is the only moment where writes must stop. With data already in sync, it usually takes a few minutes. Follow the steps in order.
-
Put the application into maintenance mode on the source, or stop the web server so no new writes arrive:
sudo systemctl stop nginxStop any workers or cron jobs that write to the database or files as well.
-
Make the source database read-only so nothing can write to it anymore:
sudo mysql -e "SET GLOBAL super_read_only = ON;" -
Stop lsyncd and run a final rsync with
--delete, so the destination becomes an exact copy:sudo systemctl disable --now lsyncd sudo rsync -aHAX --numeric-ids --delete \ -e "ssh -i /root/.ssh/id_migration" \ /var/www/ root@new_server_ip:/var/www/ -
On the destination, confirm that the replica has applied every transaction. Compare
Executed_Gtid_SetfromSHOW REPLICA STATUS\Gwith the output of this command on the source; they must match:sudo mysql -e "SELECT @@GLOBAL.gtid_executed;" -
On the destination, stop replication, remove its configuration and make the database writable:
sudo mysql -e "STOP REPLICA; RESET REPLICA ALL; SET GLOBAL read_only = OFF;"Also remove the
read_only = ONline from/etc/mysql/mysql.conf.d/mysqld.cnf, so the setting does not come back after a restart. -
Start the application on the destination and update the DNS A and AAAA records to
new_server_ip.
Step 9 - Verifying the synchronized data
Before you decommission the old server, confirm that nothing was left behind.
For files, a dry run of rsync from the source should list nothing to transfer:
sudo rsync -aHAX --numeric-ids --delete --dry-run --itemize-changes \
-e "ssh -i /root/.ssh/id_migration" \
/var/www/ root@new_server_ip:/var/www/
An empty output means the two trees are identical.
For the database, compare table checksums on both servers. Run the same query on the source and the destination and compare the results:
sudo mysql your_database -e "CHECKSUM TABLE users, orders;"
+----------------------+------------+
| Table | Checksum |
+----------------------+------------+
| your_database.users | 2310452917 |
| your_database.orders | 1876330245 |
+----------------------+------------+
Replace users, orders with your most important tables. CHECKSUM TABLE reads every row, so run it outside peak hours on large tables.
Finally, watch the web server logs on the old server. Once requests stop arriving there (after the DNS TTL has expired), the migration is complete.
Troubleshooting
Replica_IO_Running: Connecting: the replica cannot reach the source. Check that bind-address was changed on the source, that UFW allows port 3306 from new_server_ip, and that you can connect with mysql -h source_server_ip -u replicator -p from the destination.
Replica_SQL_Running: No with a duplicate key error: something wrote to the replica directly, or the dump was not consistent. The safest fix is to drop the database on the destination and repeat Step 6.
Authentication plugin 'caching_sha2_password' reported error: Authentication requires secure connection: the replica is connecting without TLS. Make sure SOURCE_SSL=1 is part of the CHANGE REPLICATION SOURCE TO statement.
Files owned by the wrong user on the destination: the UID of the application user differs between servers. Align the UIDs, or run sudo chown -R www-data:www-data /var/www/ on the destination after the final sync.
Conclusion
You copied the bulk of your data ahead of time, kept files in sync with lsyncd and the database in sync with GTID replication, and reduced the cutover to stopping writes, one final rsync and a replication check. The same pattern works for any amount of data, because the downtime depends on how much changes during the cutover, not on the total size.
As next steps, remove the migration SSH key and the replication user and firewall rule, keep the old server for a few days as a fallback before deleting it, and set up regular backups on the new server.
