Move a Website to a New VPS With Minimal Downtime

Published on

Move a website and its database to a new VPS with only minutes of downtime: lower the TTL, copy with rsync, test with curl, then freeze, sync and switch DNS.

A person holding a tablet in a data center aisle between server racks with teal and yellow network cables

You're moving a site and its database to a new VPS: another host, a bigger plan or a newer OS. Done in the wrong order, that means errors while DNS changes, orders that land on the old server after your last copy, or a certificate warning on the new one. In the order below, the site is frozen only for the few minutes of a final copy. Commands are for Ubuntu 24.04 and Debian 12 with nginx and MySQL or MariaDB; PostgreSQL and Docker get pointers.

Quick answer: A day or more ahead, lower the TTL of your A and AAAA records to 300 seconds. While the old site keeps running, copy the files and certificates with rsync and the database with mysqldump, set up nginx, and test the new server with curl --resolve or your hosts file. Then freeze the old site with a 503, dump and import the database again, rerun rsync with --delete, and point DNS at the new IP. Check MX and SPF before the switch, and keep the old server for a week as your way back.

Step 1: Take Stock of the Old Server

List what the site needs first. The commands use sudo; if you're logged in as root, leave it out. On the old server:

sudo ss -ltnp
systemctl list-units --type=service --state=running
apt list --installed 'nginx*' 'php*' 'mysql-server*' 'mariadb-server*' 'postgresql*'
ls /etc/nginx/sites-enabled/
sudo du -sh /var/www /var/lib/mysql
sudo crontab -l; sudo crontab -u www-data -l; ls /etc/cron.d; systemctl list-timers

Note the versions and PHP extensions, your site's nginx config, and the cron jobs, timers and background services that belong to the app. The database name, user and password are in the app's config; WordPress keeps them in wp-config.php. Also check how the domain's mail works; see Email Records below.

Step 2: Lower the DNS TTL in Advance

Resolvers cache your A record for its TTL: at 86400 seconds, some visitors keep reaching the old IP for up to a day after you change it. Ask your DNS provider's name servers for the real value, because any other resolver shows a cached TTL that counts down (RFC 1035):

dig +short NS example.com
dig +noall +answer example.com A @ns1.example-dns.net    # a name from the line above

The number after the name is the TTL in seconds. In your DNS dashboard, set the A and AAAA records of the domain and www to 300 seconds, at least one old TTL before the move; Google's guide to changing hosting suggests lowering it a week ahead. On Cloudflare, DNS-only records take 60 seconds to one day. Proxied records are fixed at 300 seconds, and resolvers only ever see Cloudflare's addresses (TTL, proxy status), so changing the origin IP leaves nothing in their caches to expire.

Step 3: Prepare the New VPS

Install the same stack at the same major versions where you can, and upgrade later as a separate step. Mind the defaults: Ubuntu 24.04 ships PHP 8.3, MySQL 8.0 and MariaDB 10.11; Debian 12 ships PHP 8.2 and MariaDB 10.11, and no MySQL (Launchpad, Debian tracker). Install the PHP extensions from your list, the same database server as before, and rsync, which must be on both machines (sudo apt install rsync). A Space-Node VPS is KVM with full root access, and you pick Ubuntu 24.04 LTS or Debian on the order form.

rsync matches owners by user name and falls back to the numeric ID when the name is missing, so first create any other user that owns site files (not root or www-data) under the same name.

The copies run as root on both ends, so every file is readable and keeps its owner. Give the old server's root a key for the new one:

# old server
sudo install -d -m 700 /root/.ssh
sudo ssh-keygen -t ed25519 -N '' -C migrate -f /root/.ssh/migrate
sudo cat /root/.ssh/migrate.pub
# new server, with the printed line
sudo install -d -m 700 /root/.ssh
echo 'ssh-ed25519 AAAA... migrate' | sudo tee -a /root/.ssh/authorized_keys
# old server: prints the new server's name
sudo ssh -i /root/.ssh/migrate root@203.0.113.20 hostname

OpenSSH lets root log in with a key by default (PermitRootLogin prohibit-password), and the first connection asks you to confirm the new server's host key.

Step 4: Copy the Files While the Site Runs

Run these on the old server with your new IP, and keep the shell open: Steps 5 and 7 use NEW and SSH again.

NEW=203.0.113.20
SSH='ssh -i /root/.ssh/migrate'
sudo rsync -aHAX --info=progress2 -e "$SSH" /var/www/ root@$NEW:/var/www/
sudo rsync -aHAX -e "$SSH" /etc/letsencrypt/ root@$NEW:/etc/letsencrypt/
sudo rsync -a -e "$SSH" /etc/nginx/sites-available/example.com root@$NEW:/etc/nginx/sites-available/

-a keeps permissions, owners, groups, times and symlinks; -H, -A and -X add hard links, ACLs and extended attributes, which -a leaves out (rsync). A trailing slash on the source copies the folder's contents, and later passes only send changes. Copy any snippets the site config includes too.

Take all of /etc/letsencrypt: the certificate files in live are relative symlinks into archive (Certbot source), and renewal holds each certificate's renewal settings. On the new server, install Certbot the way the old one has it (apt or snap), then enable the site:

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

nginx -t also tries to open every file the config names, so a missing certificate shows up here. Changing PHP versions? The fastcgi_pass socket contains the version: Ubuntu 24.04's PHP-FPM listens on /run/php/php8.3-fpm.sock, as the listen line in /etc/php/8.3/fpm/pool.d/www.conf shows.

Set up the cron jobs and services from Step 1 but leave them off, or they'd send the same emails and run the same jobs as the old server. For an app in Docker, copy its volumes as in our n8n migration guide.

Step 5: Copy the Database

On the old server, list the app user's privileges (use the host it connects from, usually localhost) and dump the database:

sudo mysql -u root -e "SHOW GRANTS FOR 'appuser'@'localhost'"
sudo mysqldump -u root --single-transaction --routines --events \
  --add-drop-database --databases appdb > appdb.sql
tail -n 1 appdb.sql
sudo rsync -a -e "$SSH" appdb.sql root@$NEW:/root/
  • --single-transaction takes a consistent copy of InnoDB tables without blocking the site, as long as nobody runs ALTER TABLE or similar meanwhile.
  • Triggers come along by default; --routines and --events add stored procedures, functions and events.
  • --databases writes the CREATE DATABASE and USE lines, and --add-drop-database makes each import replace the whole database.
  • A complete dump ends with a -- Dump completed on line, which tail shows.

Root logs in through the Unix socket without a password on MySQL and MariaDB alike, and on MariaDB, mysql and mysqldump are links to mariadb and mariadb-dump.

On the new server, create the user exactly as SHOW GRANTS named it, with the password from the app's config, and repeat its grants. For most apps that is:

sudo mysql -u root
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'password-from-your-config';
GRANT ALL ON appdb.* TO 'appuser'@'localhost';
exit

Then import: sudo mysql -u root -e 'source /root/appdb.sql'.

On PostgreSQL, dump each database with pg_dump -Fc and the roles with pg_dumpall --globals-only, since pg_dump leaves roles out, and restore both as in our PostgreSQL backup and restore guide.

Step 6: Test the New Server Before DNS

From your own computer:

curl -sS -o /dev/null -w '%{http_code}\n' \
  --resolve example.com:443:203.0.113.20 https://example.com/

--resolve makes curl use that address instead of DNS, and without -k curl verifies the certificate. A 200, or the redirect your home page normally gives, means nginx, PHP, the database login and the certificate work. Error 60 means curl couldn't verify the certificate: check the site's server_name and ssl_certificate lines.

For a browser, add 203.0.113.20 example.com www.example.com to your hosts file: /etc/hosts on Linux, or C:\Windows\System32\drivers\etc\hosts on Windows, edited in Notepad run as administrator. Try pages, images, logins, forms and downloads while sudo tail -f /var/log/nginx/error.log runs on the new server, then remove the line. Anything you create while testing disappears in the final sync.

Step 7: Freeze, Sync and Switch

Pick a quiet hour. On the old server, add return 503; right below server_name in each of the site's server blocks; nginx runs a server-level return before it picks a location (rewrite module). Then stop cron and the app's workers, so nothing writes to the database:

sudo nginx -t && sudo systemctl reload nginx
sudo systemctl stop cron

A few minutes of 503 is harmless for search: Google's crawlers slow down on 5xx answers and only drop indexed URLs when the errors persist. Now run the mysqldump, tail and rsync lines from Step 5 again, then sync the files:

sudo rsync -aHAXi --delete --dry-run -e "$SSH" /var/www/ root@$NEW:/var/www/
sudo rsync -aHAX --delete -e "$SSH" /var/www/ root@$NEW:/var/www/

The dry run lists every change, with *deleting before files only the new server has; read it first, because the rsync manual calls --delete dangerous when used incorrectly. Then:

  1. On the new server, import again with sudo mysql -u root -e 'source /root/appdb.sql'. The dump's DROP DATABASE line clears your test data.
  2. In your DNS dashboard, point the A and AAAA records of the domain and www at the new addresses. Behind a Cloudflare proxy, you change the origin IP.
  3. On the new server, add the crontabs (sudo crontab -u www-data -e) and start the services with sudo systemctl enable --now yourapp.service. Once the 300-second TTL has passed, run sudo certbot renew --dry-run to test renewal: its http-01 check on port 80 has to reach the new server.

Visitors whose resolver still has the old IP get the 503 until their cache expires. To give them the new site instead, keep the listen, server_name and ssl_ lines of the old server's HTTPS block, replace the rest with this, and reload nginx:

location / {
    proxy_pass https://203.0.113.20;
    proxy_set_header Host $host;
    proxy_ssl_server_name on;
    proxy_ssl_name $host;
}

By default nginx sends the upstream's address as Host and no SNI (proxy module), so the new server wouldn't know which site you mean. It doesn't check the new server's certificate either (proxy_ssl_verify is off by default), but the traffic is still encrypted.

Step 8: Watch, Then Clean Up

Follow sudo tail -f /var/log/nginx/access.log on both servers. Google's hosting guide expects a short dip in Googlebot's crawl rate after a move and says to shut the old server down once its traffic reaches zero. Until then it's your way back: point DNS back, restore its site config and start cron again, but copy back anything written on the new server meanwhile. After a few quiet days, raise the TTL again, delete the line ending in migrate from /root/.ssh/authorized_keys on the new server and /root/.ssh/migrate* on the old one, and remove the appdb.sql files, which hold your users' data.

Email Records

Moving the A record can move mail too. Check before the switch:

dig +short MX example.com
dig +short TXT example.com
  • No MX record: senders then use the domain's A record as an implicit MX (RFC 5321), so your mail would follow the site. Add MX records for your mail host first.
  • MX points at a mail server on the old VPS: keep that host name on the old IP until you move mail separately (see our mail server guide).
  • The site sends mail itself: an SPF a mechanism follows the A record, but an ip4: entry with the old IP must change (RFC 7208). Gmail also requires valid forward and reverse DNS (PTR) on the sending IP. Sending through your mail provider's SMTP server avoids both.

If It Still Fails

  • 502 Bad Gateway on PHP pages: the fastcgi_pass socket doesn't match the installed PHP-FPM (Step 4). For Node apps, see our 502 guide.
  • Database connection errors: the user, host or password differs from the app's config. Compare SHOW GRANTS on both servers.
  • Cloudflare error 521 or 522: the new server refuses or drops Cloudflare's connections; see our Error 521 guide.
  • The old server still gets visitors days later: look for a record you missed with dig +short AAAA example.com and dig +short www.example.com.

FAQ

How long does the DNS switch take? Up to the TTL resolvers cached the old record with: minutes, if you set 300 seconds well ahead, though Cloudflare notes that local DNS caches can take longer.

Can I move without the freeze? For a static site, yes: nothing changes between copies. For a site with orders, comments or accounts, the freeze keeps writes out of the old database after your last dump, and the trial run in Steps 4 and 5 keeps it short.

Do I need a new SSL certificate? No. The copied /etc/letsencrypt works on the new server, and Certbot renews it there once DNS points at it; sudo certbot renew --dry-run confirms that.

Are backups included with a Space-Node VPS? Not in the plan price, but you can arrange them through a support ticket. Keep the old server until the new one has a backup of its own.

Sources

All opened on 30 October 2026: