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
sudoprivileges. - 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_ipandnew_server_ip: the public IPv4 addresses of both servers.your_user: your sudo user, the same name on both servers.
Notethe database steps use MySQL 8.0 syntax (
CHANGE REPLICATION SOURCE TO,START REPLICA). MariaDB supports the same approach, but its replication commands and defaults differ.
Here is the plan:
- Lower the DNS TTL a day in advance.
- Build the new server and copy the files while the old one stays live.
- Make the new database a read-only replica of the old one, so it stays current.
- Test the new server without touching DNS.
- Cut over in seconds: freeze writes, sync, promote the replica, and proxy the old server to the new one.
- 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
Warningcheck that UFW is active on the old server with
sudo ufw status. If it is inactive, MySQL is now reachable from the whole internet. Enable the firewall (allowing SSH and web traffic first) before continuing.
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.
-
Freeze writes on the old database. The application user loses write access; reads keep working:
sudo mysql -e "SET GLOBAL read_only = ON" -
Run the final file sync from the new server.
--deleteremoves 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/ -
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 inRelay_Source_Log_FileandExec_Source_Log_Pos. -
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-dbandread_onlylines you added to/etc/mysql/mysql.conf.d/mysqld.cnf, so that the next restart does not make the database read-only again. -
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.
-
Update DNS. In your DNS panel, point the
Arecords ofyour_domainandwww.your_domaintonew_server_ip(and updateAAAArecords if you use IPv6). -
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
Tipwhile the proxy is active, the new server logs the old server's IP for proxied requests. If you need the real visitor IPs in those logs, add
set_real_ip_from old_server_ip;andreal_ip_header X-Forwarded-For;to the site'sserverblock on the new server.
When the old server has received no requests for at least 48 hours, clean up:
- On the old server, delete
/etc/sudoers.d/migration-rsyncand drop the replication user withsudo 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: ConnectingwithLast_IO_Error: error connecting to master: the new server cannot reach port 3306 on the old one. Checkbind-address, the UFW rule on the old server, and that the replication user's host matchesnew_server_ipexactly.Authentication plugin 'caching_sha2_password' reported error: Authentication requires secure connection:SOURCE_SSL = 1is missing fromCHANGE REPLICATION SOURCE TO. RunSTOP REPLICA, repeat the command with it, and start again.Last_SQL_Errormentions a table that does not exist: a change for another database was not filtered. Confirmreplicate-do-dbis set in the new server's configuration and that MySQL was restarted after adding it.rsync: connection unexpectedly closedwithsudo: 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 withsudo -l -U your_useron 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.
