--- title: "How to Migrate a Website to a New Server Without Downtime" description: "Move an Nginx, PHP and MySQL website to a new Ubuntu 24.04 server with rsync, MySQL replication and a temporary reverse proxy, so visitors never see an outage." category: "Server Migration" language: en source: https://cubepath.com/docs/server-migration/website-migration-without-downtime updated: 2026-09-25 --- # How to Migrate a Website to a New Server Without Downtime 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. > Note: the 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: 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: ```bash dig +noall +answer your_domain A ``` ```output 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: ```bash 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: ```bash 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: ```bash php -m ``` Open the web ports in the firewall of the new server: ```bash sudo ufw allow 'Nginx Full' sudo ufw status ``` ```output 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: ```bash 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: ```bash sudo visudo -f /etc/sudoers.d/migration-rsync ``` ```text 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: ```bash 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: ```bash 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: ```bash sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ``` ```output 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**: ```bash sudo mysql -e "SHOW VARIABLES WHERE Variable_name IN ('log_bin', 'server_id', 'binlog_format')" ``` ```output +---------------+-------+ | 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: ```bash 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: ```bash sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf ``` Change the `bind-address` line in the `[mysqld]` section: ```ini 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: ```bash sudo ufw allow from new_server_ip to any port 3306 proto tcp sudo systemctl restart mysql ``` > Warning: check 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: ```bash mysql -h old_server_ip -u repl -p --ssl-mode=REQUIRED -e "SELECT 'connected'" ``` ```output +-----------+ | 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: ```bash sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf ``` Add these lines at the end of the `[mysqld]` section: ```ini 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: ```bash 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: ```bash 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: ```bash scp your_user@old_server_ip:your_database.sql.gz ~/ zcat ~/your_database.sql.gz | grep -m 1 -E 'CHANGE (MASTER|REPLICATION SOURCE) TO' ``` ```output -- 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: ```bash 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`: ```bash 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: ```sql 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: ```sql SHOW REPLICA STATUS\G ``` ```output 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: ```bash 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: ```bash curl -sI --resolve your_domain:443:new_server_ip https://your_domain/ ``` ```output 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): ```text 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: ```bash 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: ```bash sudo nano /etc/nginx/sites-available/your_domain-proxy ``` ```nginx 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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. 6. **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). 7. **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: ```bash dig +short your_domain A ``` ```output 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: ```bash sudo certbot renew --dry-run ``` ```output 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: ```bash sudo tail -f /var/log/nginx/access.log ``` > Tip: while 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;` and `real_ip_header X-Forwarded-For;` to the site's `server` block 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-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.