Moving a website to a new server usually means copying files, dumping the database, and changing DNS, while visitors hit an outdated or half-migrated site for hours. You can avoid that by keeping the new server continuously in sync and switching traffic in a few seconds. In this tutorial you will migrate an Nginx, PHP-FPM and MySQL website (such as WordPress) to a new Ubuntu 24.04 server using rsync for files, MySQL replication for the database, and a temporary reverse proxy on the old server that sends every request to the new one while DNS propagates.

Prerequisites

To follow this tutorial, you will need:

  • The old server currently running the site, with Nginx, PHP-FPM, MySQL 8.0 and a Let's Encrypt certificate, and a non-root user with sudo privileges.
  • A new server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root sudo user and UFW enabled.
  • Access to your domain's DNS records.
  • Enough disk space on the new server for the website files and the database.

The guide uses these placeholders; replace them everywhere:

  • your_domain: the site's domain, served from /var/www/your_domain.
  • your_database: the site's MySQL database.
  • old_server_ip and new_server_ip: the public IPv4 addresses of both servers.
  • your_user: your sudo user, the same name on both servers.

Here is the plan:

  1. Lower the DNS TTL a day in advance.
  2. Build the new server and copy the files while the old one stays live.
  3. Make the new database a read-only replica of the old one, so it stays current.
  4. Test the new server without touching DNS.
  5. Cut over in seconds: freeze writes, sync, promote the replica, and proxy the old server to the new one.
  6. Change DNS and retire the old server once traffic has moved.

Step 1 - Lowering the DNS TTL

Resolvers cache your DNS records for the TTL (time to live) of the record. If your A record has a TTL of 86400 seconds, some visitors would keep reaching the old server for a full day after you change it. Check the current value:

dig +noall +answer your_domain A
your_domain.		3600	IN	A	old_server_ip

The second column is the TTL in seconds. In your DNS provider's panel, lower the TTL of the A (and AAAA, if present) records for your_domain and www.your_domain to 300 seconds. Do this at least one old TTL period before the cutover (24 to 48 hours ahead is safe), so that the long cached values have expired by then.

Step 2 - Preparing the new server

Install the same stack on the new server. Check the versions on the old server first, so that you do not upgrade PHP or MySQL by accident during the move:

nginx -v; php -v | head -n 1; mysql --version

Ubuntu 24.04 ships Nginx 1.24, PHP 8.3 and MySQL 8.0. If the old server runs a much older PHP version, test the application on PHP 8.3 before migrating. On the new server, install the packages, including the PHP extensions your application uses:

sudo apt update
sudo apt install nginx mysql-server php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip php8.3-intl certbot python3-certbot-nginx

Compare the loaded PHP extensions on both servers; any module missing on the new side must be installed:

php -m

Open the web ports in the firewall of the new server:

sudo ufw allow 'Nginx Full'
sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
Nginx Full                 ALLOW       Anywhere

Step 3 - Copying the files

Files are copied from the new server, which pulls them over SSH with rsync. Some files, like the Let's Encrypt private keys or a wp-config.php with mode 640, can only be read by root, so rsync must run as root on both ends.

First, create an SSH key on the new server and install it for your_user on the old server:

ssh-keygen -t ed25519
ssh-copy-id your_user@old_server_ip

Then, on the old server, allow your_user to run rsync as root without a password prompt, which the remote side of rsync needs. Create a sudoers drop-in with visudo, which checks the syntax before saving:

sudo visudo -f /etc/sudoers.d/migration-rsync
your_user ALL=(root) NOPASSWD: /usr/bin/rsync

You will delete this file when the migration is finished. Back on the new server, pull the website files. -aHAX preserves permissions, owners, hard links, ACLs and extended attributes; --numeric-ids keeps user and group IDs as numbers, which is correct because www-data has ID 33 on every Ubuntu system:

sudo rsync -aHAX --numeric-ids --info=progress2 \
    -e "ssh -i /home/your_user/.ssh/id_ed25519" \
    --rsync-path="sudo rsync" \
    your_user@old_server_ip:/var/www/your_domain/ /var/www/your_domain/

The first time, SSH asks you to confirm the old server's host key; type yes. Copy the Nginx site configuration and the whole Let's Encrypt directory, which includes the certificates, private keys and renewal settings:

sudo rsync -aHAX --numeric-ids -e "ssh -i /home/your_user/.ssh/id_ed25519" --rsync-path="sudo rsync" \
    your_user@old_server_ip:/etc/letsencrypt/ /etc/letsencrypt/
sudo rsync -a -e "ssh -i /home/your_user/.ssh/id_ed25519" --rsync-path="sudo rsync" \
    your_user@old_server_ip:/etc/nginx/sites-available/your_domain /etc/nginx/sites-available/your_domain

If you customized PHP-FPM (files in /etc/php/*/fpm/pool.d/ or php.ini), copy those settings by hand, adjusting the PHP version in any paths. Enable the site and check 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 configuration points to a PHP-FPM socket of another version, such as /run/php/php8.1-fpm.sock, change it to /run/php/php8.3-fpm.sock first. You can run the same rsync command again at any time; it only transfers what changed.

Step 4 - Preparing the old database as a replication source

MySQL replication makes the new server apply every change that happens on the old database, in near real time, until you switch over. MySQL 8.0 has binary logging enabled by default. Confirm it on the old server:

sudo mysql -e "SHOW VARIABLES WHERE Variable_name IN ('log_bin', 'server_id', 'binlog_format')"
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| binlog_format | ROW   |
| log_bin       | ON    |
| server_id     | 1     |
+---------------+-------+

Create a replication user that may only connect from the new server. Replace your_repl_password with a strong, unique password:

sudo mysql -e "CREATE USER 'repl'@'new_server_ip' IDENTIFIED BY 'your_repl_password'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'new_server_ip';"

By default, MySQL on Ubuntu only listens on 127.0.0.1. Open the configuration file:

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

Change the bind-address line in the [mysqld] section:

bind-address = 0.0.0.0

Allow port 3306 only from the new server and restart MySQL. The restart interrupts database connections for a second or two, so do it at a quiet time:

sudo ufw allow from new_server_ip to any port 3306 proto tcp
sudo systemctl restart mysql

From the new server, confirm that the replication user can connect over TLS:

mysql -h old_server_ip -u repl -p --ssl-mode=REQUIRED -e "SELECT 'connected'"
+-----------+
| connected |
+-----------+
| connected |
+-----------+

Step 5 - Seeding the new database and starting replication

First, prepare the new MySQL server to act as a replica. Open its configuration:

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

Add these lines at the end of the [mysqld] section:

server-id       = 2
replicate-do-db = your_database
read_only       = ON

server-id must differ from the old server's. replicate-do-db applies only changes to the site's database, ignoring other databases on the old server. read_only blocks writes from normal users such as the application account, so testing the new site cannot change data behind replication's back. Restart MySQL:

sudo systemctl restart mysql

On the old server, take a consistent dump that records the binary log position at the moment of the snapshot. --source-data=2 writes that position into the dump as a comment. It briefly takes a global read lock at the start of the dump, normally for well under a second:

sudo mysqldump --single-transaction --source-data=2 --routines --triggers --events --databases your_database | gzip > ~/your_database.sql.gz

On the new server, copy the dump over, find the recorded position, and import it:

scp your_user@old_server_ip:your_database.sql.gz ~/
zcat ~/your_database.sql.gz | grep -m 1 -E 'CHANGE (MASTER|REPLICATION SOURCE) TO'
-- CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='binlog.000014', SOURCE_LOG_POS=5893;

Write down the file name and position. Import the dump; because it was created with --databases, it creates your_database itself:

zcat ~/your_database.sql.gz | sudo mysql

Create the application's database user with the same name and password it has on the old server. You can find them in the application's configuration file, for example DB_USER and DB_PASSWORD in wp-config.php:

sudo mysql -e "CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'app_password'; GRANT ALL PRIVILEGES ON your_database.* TO 'app_user'@'localhost';"

Now connect replication, using the file and position from the dump. Open a MySQL shell with sudo mysql and run:

CHANGE REPLICATION SOURCE TO
    SOURCE_HOST = 'old_server_ip',
    SOURCE_USER = 'repl',
    SOURCE_PASSWORD = 'your_repl_password',
    SOURCE_LOG_FILE = 'binlog.000014',
    SOURCE_LOG_POS = 5893,
    SOURCE_SSL = 1;
START REPLICA;

Check the replication status:

SHOW REPLICA STATUS\G
             Replica_IO_Running: Yes
            Replica_SQL_Running: Yes
                Replicate_Do_DB: your_database
                     Last_Error:
          Seconds_Behind_Source: 0

Both threads must say Yes and Last_Error must be empty. Type exit to leave the shell. To see replication working, compare a row count on both servers; for WordPress, publish a draft on the live site and run this on both:

sudo mysql -N -e "SELECT COUNT(*) FROM your_database.wp_posts"

The numbers should match within a second or two.

Step 6 - Testing the new server

Test the new server with the real domain name, without changing DNS. curl --resolve connects to a given IP address while using your domain for TLS and the Host header:

curl -sI --resolve your_domain:443:new_server_ip https://your_domain/
HTTP/2 200
server: nginx/1.24.0 (Ubuntu)
content-type: text/html; charset=UTF-8

To browse the site, add a temporary line to the hosts file on your own computer (/etc/hosts on Linux and macOS, C:\Windows\System32\drivers\etc\hosts on Windows):

new_server_ip your_domain www.your_domain

Click through the important pages, check images and forms, and watch the error log on the new server:

sudo tail -f /var/log/nginx/error.log

Logging in or submitting forms will fail at this point, because the database is read-only; that is expected. Remove the hosts file line when you are done.

Step 7 - Cutting over

The cutover takes a minute or two. During that time the site keeps serving pages, and only writes (comments, orders, logins) are rejected for a few seconds. Prepare the proxy configuration on the old server before you start.

On the old server, create a site configuration that forwards all requests to the new server over HTTPS:

sudo nano /etc/nginx/sites-available/your_domain-proxy
server {
    listen 80;
    listen [::]:80;
    server_name your_domain www.your_domain;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name your_domain www.your_domain;

    ssl_certificate     /etc/letsencrypt/live/your_domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;

    client_max_body_size 64m;

    location / {
        proxy_pass https://new_server_ip;
        proxy_ssl_server_name on;
        proxy_ssl_name $host;
        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;
    }
}

Now run the cutover in this order.

  1. Freeze writes on the old database. The application user loses write access; reads keep working:

    sudo mysql -e "SET GLOBAL read_only = ON"
    
  2. Run the final file sync from the new server. --delete removes files that were deleted on the old server since the first copy:

    sudo rsync -aHAX --numeric-ids --delete -e "ssh -i /home/your_user/.ssh/id_ed25519" --rsync-path="sudo rsync" \
        your_user@old_server_ip:/var/www/your_domain/ /var/www/your_domain/
    
  3. Confirm the replica has every change. On the old server, sudo mysql -e "SHOW MASTER STATUS" shows the current binary log file and position. On the new server, sudo mysql -e "SHOW REPLICA STATUS\G" must show the same values in Relay_Source_Log_File and Exec_Source_Log_Pos.

  4. Promote the new database. On the new server, stop replication, discard its settings and allow writes:

    sudo mysql -e "STOP REPLICA; RESET REPLICA ALL; SET GLOBAL read_only = OFF;"
    

    Then remove the server-id, replicate-do-db and read_only lines you added to /etc/mysql/mysql.conf.d/mysqld.cnf, so that the next restart does not make the database read-only again.

  5. Switch the old server to proxy mode. Replace the site with the proxy configuration and reload Nginx:

    sudo rm /etc/nginx/sites-enabled/your_domain
    sudo ln -s /etc/nginx/sites-available/your_domain-proxy /etc/nginx/sites-enabled/
    sudo nginx -t && sudo systemctl reload nginx
    

From this moment, every request, whether it reaches the old or the new IP address, is answered by the new server with the new database. Verify it through the old server:

curl -sI --resolve your_domain:443:old_server_ip https://your_domain/

A 200 response, and a matching request in /var/log/nginx/access.log on the new server, confirm that the proxy works. Log in to the application and make a test change to confirm writes work too.

  1. Update DNS. In your DNS panel, point the A records of your_domain and www.your_domain to new_server_ip (and update AAAA records if you use IPv6).

  2. Stop scheduled jobs on the old server. Disable application cron jobs there (sudo crontab -u www-data -l, /etc/cron.d/), so that they do not run against the frozen database, and enable the same jobs on the new server.

Step 8 - Verifying and cleaning up

Check that DNS returns the new address:

dig +short your_domain A
new_server_ip

Confirm that certificate renewal works on the new server. The dry run uses the real domain, so run it after the DNS change:

sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/your_domain/fullchain.pem (success)

Requests that still arrive at the old server are proxied, so they show up in the old server's access log. Watch it decrease over the next day or two:

sudo tail -f /var/log/nginx/access.log

When the old server has received no requests for at least 48 hours, clean up:

  • On the old server, delete /etc/sudoers.d/migration-rsync and drop the replication user with sudo mysql -e "DROP USER 'repl'@'new_server_ip'".
  • Take a final backup of the old server, then shut it down.
  • Raise the DNS TTL back to its previous value, such as 3600 seconds.
  • Configure backups and monitoring on the new server before you forget.

Rolling back

Until step 4 of the cutover, rolling back is simple: run sudo mysql -e "SET GLOBAL read_only = OFF" on the old server and it is live again, with nothing changed. After the new database is promoted, it holds writes that the old server does not have. Rolling back then means exporting those changes (or the whole database) from the new server and loading them into the old one, so run your checks carefully before step 4, and decide on rollback quickly if something is wrong right after it.

Troubleshooting

  • Replica_IO_Running: Connecting with Last_IO_Error: error connecting to master: the new server cannot reach port 3306 on the old one. Check bind-address, the UFW rule on the old server, and that the replication user's host matches new_server_ip exactly.
  • Authentication plugin 'caching_sha2_password' reported error: Authentication requires secure connection: SOURCE_SSL = 1 is missing from CHANGE REPLICATION SOURCE TO. Run STOP REPLICA, repeat the command with it, and start again.
  • Last_SQL_Error mentions a table that does not exist: a change for another database was not filtered. Confirm replicate-do-db is set in the new server's configuration and that MySQL was restarted after adding it.
  • rsync: connection unexpectedly closed with sudo: a terminal is required to read the password: the sudoers drop-in on the old server is missing or has a different rsync path. Check it with sudo -l -U your_user on the old server.
  • 502 Bad Gateway from the old server after switching to proxy mode: the old server cannot reach the new one over HTTPS, or the new server's site does not answer for the requested host name. Test from the old server with curl -sI --resolve your_domain:443:new_server_ip https://your_domain/.

Conclusion

You moved a live website to a new server with no outage: files were pre-copied with rsync, the database stayed in sync through MySQL replication, and a reverse proxy on the old server covered the DNS propagation window, so the only visible effect was a few seconds in which writes were paused. As next steps, you can:

  • Set up nightly database dumps and offsite backups on the new server.
  • Tune PHP-FPM and MySQL for the new server's CPU and memory.
  • Reuse the same replication and proxy technique for your next move, for example to a larger server or another region.