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 sudo privileges on both servers.
  • Enough disk space on the destination for all application files and the database.
  • MySQL 8.0 (the mysql-server package 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.

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:

  • -a preserves permissions, timestamps, symlinks, owners and groups.
  • -H, -A and -X preserve hard links, ACLs and extended attributes.
  • --numeric-ids keeps 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.

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

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.

  1. Put the application into maintenance mode on the source, or stop the web server so no new writes arrive:

    sudo systemctl stop nginx
    

    Stop any workers or cron jobs that write to the database or files as well.

  2. Make the source database read-only so nothing can write to it anymore:

    sudo mysql -e "SET GLOBAL super_read_only = ON;"
    
  3. 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/
    
  4. On the destination, confirm that the replica has applied every transaction. Compare Executed_Gtid_Set from SHOW REPLICA STATUS\G with the output of this command on the source; they must match:

    sudo mysql -e "SELECT @@GLOBAL.gtid_executed;"
    
  5. 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 = ON line from /etc/mysql/mysql.conf.d/mysqld.cnf, so the setting does not come back after a restart.

  6. 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.