Guides

Server migration checklist: move to a new VPS with near-zero downtime

Migrate a server to a new VPS with near-zero downtime: inventory, lower the DNS TTL, rsync files, dump databases, test via hosts file, sync again, switch DNS.

Published ~12 min read

Short answer: to migrate a server with near-zero downtime, build the new server next to the old one and copy files and databases while the old one keeps serving. Test the new server through your hosts file, then pause writes for a short final sync and switch DNS. Visitors only notice that final sync, usually a few minutes. The old server stays up as your way back until every cached DNS answer has expired.

This checklist covers moving a Linux server you run yourself to a new VPS: a website, an API, a bot or a game host, with root on both machines. If you are leaving a shared hosting account without root, read moving from shared hosting to a VPS instead. It covers panel exports, PHP versions and mail.

The migration checklist at a glance

WhenWhat you doWhy
Two days beforeInventory the old server, lower the DNS TTL to 300 secondsResolvers must forget the old, long TTL before cutover day
One day beforeBuild the new server, run the first rsync, restore a test copy of each databaseThe bulk of the data moves while the old server still serves
TestPoint your own hosts file at the new IP and click through everythingYou find what is broken before a visitor does
CutoverStop writes, run the final rsync and database dump, change the A and AAAA recordsOnly what changed since the first pass is copied, so this takes minutes
The week afterKeep the old server running, watch both logs, then shut it downStale DNS caches and forgotten cron jobs show up here

Should you rebuild or copy the whole disk?

There are three ways to move a server. Between two VPS, usually at two different providers, rebuilding and copying the data is almost always the right one:

MethodHow it worksDowntimeUse it when
Rebuild + rsync (this guide)Fresh OS on the new server, same software installed, data and config copied overMinutes, for the final syncAlmost always. You also end up on a clean, current OS
Full disk imageThe whole disk is copied block by block, for example with dd over SSHLong: the source has to stay idle while it copiesRarely between providers. Network config, drivers and disk IDs come along and often break the first boot
Database replicationThe new database runs as a replica of the old one until you promote itVery short: only the switch to the new databaseThe database is too big to dump and restore inside your maintenance window

Step 1: Inventory the old server

Most broken migrations break on something nobody knew was running. Collect the facts on the old server and keep the output, because it is your checklist for the new one:

Debian · Ubuntu · on the old server
cat /etc/os-release                                   # distribution and version
apt-mark showmanual > ~/manual-packages.txt           # packages someone installed on purpose
systemctl list-units --type=service --state=running   # what runs
sudo ss -tulpn                                        # every listening port and its process
systemctl list-timers --all                           # systemd timers
sudo ls /var/spool/cron/crontabs /etc/cron.d          # user crontabs, system cron files
df -h && sudo du -xsh /var/www /srv /home /opt 2>/dev/null   # how much data moves

Then write down what no command shows you:

  • Everything tied to the old IP address: partner firewalls and API allowlists, a remote database that grants access to this host, the ip4: part of your SPF record, uptime monitors.
  • Every DNS record that points here, not only the A record: AAAA, MX, and any subdomain that is easy to forget, like api. or mail..
  • Secrets and hand-made config: .env files, nginx or Apache sites, unit files in /etc/systemd/system, /etc/letsencrypt, the authorized_keys of every user who logs in.
  • Mail: if the server sends mail, the new IP needs its own reverse DNS (PTR) record and a matching SPF entry, or your mail lands in spam.

Step 2: Lower the DNS TTL a day or two ahead

The TTL tells resolvers how many seconds they may cache an answer. With a TTL of 86400 (one day), a resolver that looked up your domain this morning keeps the old IP until tomorrow morning, whatever you change in between. So set the TTL of the records you will change to 300 seconds, at least one full old TTL before the move. On cutover day, the world then follows within minutes.

check what your nameserver hands out
dig +short NS example.com
dig @ns1.your-dns-provider.example example.com A +noall +answer
# example.com.   86400   IN   A   198.51.100.10
#                ^ TTL in seconds

Step 3: Build the new server

Do the base setup first: a sudo user, key-only SSH, a firewall and automatic security updates, as in the initial server setup guide. Then install the software from your inventory. A newer OS is fine, and often the point of the move. If it ships a newer major version of your database server, that usually works, because logical dumps can generally be loaded into a newer version. Stay on the same engine, though: MariaDB to MariaDB, MySQL to MySQL. Switching engines during the move gives you two sources of trouble at once. Test your application in step 6 all the same.

Create the users and groups that own your files (your app user, a deploy user) before you copy anything. rsync matches owners by name and only falls back to the raw user ID when a name does not exist on the new server, which is how files end up owned by a stranger.

Finally, give the new server a temporary key to the old one, so it can pull data directly. Install rsync on both machines if it is missing (apt install rsync).

Debian · Ubuntu · on the new server, as root
ssh-keygen -t ed25519 -f /root/.ssh/migrate -N ''
cat /root/.ssh/migrate.pub
# add that line to /root/.ssh/authorized_keys on the OLD server, then test:
ssh -i /root/.ssh/migrate root@198.51.100.10 hostname

If the old server does not allow root logins, use a sudo user instead and add --rsync-path="sudo rsync" to the rsync commands below. That user must be able to run rsync through sudo without a password prompt. Remove the key from the old server once the move is done.

Step 4: Copy the files with rsync, first pass

Pull from the new server, so the data flows straight between the two machines instead of through your laptop. This first pass moves the bulk of the data while the old site keeps running:

Debian · Ubuntu · on the new server, as root
OLD=198.51.100.10
SSH="ssh -i /root/.ssh/migrate"

# dry run first: read the list of what it would do
rsync -aHAXv --dry-run -e "$SSH" root@$OLD:/var/www/ /var/www/

# then for real, with an overall progress line
rsync -aHAX --info=progress2 -e "$SSH" root@$OLD:/var/www/ /var/www/
rsync -aHAX -e "$SSH" root@$OLD:/etc/letsencrypt/ /etc/letsencrypt/

# old /etc goes to a side folder, never over the new /etc
rsync -aHAX -e "$SSH" root@$OLD:/etc/ /root/old-etc/
diff -ru /root/old-etc/nginx /etc/nginx | less
  • -a keeps permissions, owners, timestamps and symlinks. It does not keep hard links, ACLs or extended attributes. -H, -A and -X add those. -v on the dry run makes it list each file; without it a dry run prints nothing.
  • The trailing slash on the source copies the contents of the directory. Without it you get /var/www/www/. The guide to copying files to a VPS covers this and the other rsync and scp traps.
  • Repeat the command for /srv, /home, /opt or wherever your inventory found data. Add --exclude for caches and logs you do not need.
  • User crontabs live in /var/spool/cron/crontabs, not in /etc. Save each one with sudo crontab -l -u appuser on the old server, and install it with sudo crontab -e -u appuser on the new one at cutover, not before. Otherwise every job runs twice, once on each server.
  • Copying /etc/letsencrypt as a whole keeps the symlinks from live/ into archive/ intact. The new server then has a valid certificate for testing, before DNS points at it.

Step 5: Dump and restore the databases

Stream a logical dump over SSH straight into the new database server. Do it once now as a rehearsal, so you know it works and how long it takes, then again at cutover.

MariaDB and MySQL

Debian · Ubuntu · on the new server, as root
OLD=198.51.100.10
ssh -i /root/.ssh/migrate root@$OLD \
  'mariadb-dump --single-transaction --routines --triggers --events --databases appdb' \
  | mariadb

On MySQL the tools are called mysqldump and mysql, with the same options. --single-transaction gives a consistent dump without locking the live site, but only for InnoDB tables. Old MyISAM tables stay consistent only if nothing writes to them during the dump. Running the same command again later replaces every table, because the dump drops each table before it re-creates it.

Database accounts are not part of that dump. On MariaDB, --system=users writes every account, role and grant as SQL, and --insert-ignore turns each CREATE USER into CREATE USER IF NOT EXISTS:

MariaDB · on the new server, as root
OLD=198.51.100.10
ssh -i /root/.ssh/migrate root@$OLD \
  'mariadb-dump --system=users --insert-ignore' > /root/users.sql
nano /root/users.sql    # keep your app users, delete root, mariadb.sys and mysql
mariadb < /root/users.sql

On MySQL, run SHOW CREATE USER and SHOW GRANTS for each app user on the old server and replay the output on the new one.

PostgreSQL

Debian · Ubuntu · on the new server, as root
OLD=198.51.100.10
# roles and tablespaces: pg_dump leaves them out
ssh -i /root/.ssh/migrate root@$OLD 'sudo -u postgres pg_dumpall --globals-only' \
  | sudo -u postgres psql

# the database, in custom format, then restore (-C creates it)
ssh -i /root/.ssh/migrate root@$OLD 'sudo -u postgres pg_dump -Fc appdb' > /root/appdb.dump
sudo -u postgres pg_restore -C -d postgres < /root/appdb.dump

The error role "postgres" already exists is expected and harmless. For the final pass, add --clean --if-exists to pg_restore: it then drops and re-creates appdb on the new server before it loads the fresh dump.

Step 6: Test the new server through your hosts file

Make your own computer believe the domain has already moved, while everyone else still reaches the old server. Add one line to your hosts file: /etc/hosts on Linux and macOS, C:\Windows\System32\drivers\etc\hosts on Windows (open the editor as administrator).

hosts file
203.0.113.20   example.com www.example.com

For a quick check without editing anything, curl can do the same per request:

Linux · macOS
curl -sSI --resolve example.com:443:203.0.113.20 https://example.com/

Then use the site the way your users do: log in, save something, upload a file, send a password-reset mail, open the admin area, call the API, and check that background jobs and cron tasks run. Whatever you write during testing is overwritten by the final sync. Remove the hosts line when you are done, or you will not notice DNS problems later.

Step 7: Run the final sync and switch DNS

  1. 1Stop writes on the old server

    Turn on maintenance mode, or stop the services that write data (systemctl stop yourapp) and the cron jobs that do. Stop the app on the new server too if it still runs from your tests: the final database pass replaces its data, and PostgreSQL refuses to drop a database that still has connections.

  2. 2Run the final rsync with --delete

    --delete removes files on the new server that no longer exist on the old one. With a wrong path it deletes the wrong things, so dry-run it and read the list first. Repeat it for every directory you copied in step 4.

    on the new server, as root
    OLD=198.51.100.10
    rsync -aHAXv --delete --dry-run -e "ssh -i /root/.ssh/migrate" root@$OLD:/var/www/ /var/www/
    rsync -aHAX --delete -e "ssh -i /root/.ssh/migrate" root@$OLD:/var/www/ /var/www/
  3. 3Run the final database pass

    The same commands as in step 5. They take as long as the rehearsal did.

  4. 4Start the services on the new server

    Start your app, install the crontabs, then check one request with the curl --resolve line from step 6.

  5. 5Change the A and AAAA records

    Point both to the new addresses. A forgotten AAAA record keeps sending every IPv6 visitor to the old server. If the new server has no IPv6 address, delete the AAAA record.

  6. 6Watch the traffic move

    Run sudo tail -f /var/log/nginx/access.log on both servers. The new log fills up, the old one goes quiet. The DNS propagation checker shows which of twelve public resolvers already hand out the new address.

Step 8: Keep the old server until the caches have expired

With a 300-second TTL most visitors move within minutes, but some resolvers and local caches hold on longer. Keep the old server running for a few days. Its app is stopped, so the safest thing it can do is forward stragglers to the new server. Set this up right after the DNS change. With nginx, keep the listen, server_name and ssl_certificate lines of the site’s HTTPS server block on the old server and replace everything else in it with one location:

nginx · on the old server
location / {
    proxy_pass https://203.0.113.20;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_ssl_server_name on;
    proxy_ssl_name $host;
}

Leave no other location in that block. A leftover PHP location would still catch every .php request and answer it with a 502 from the stopped backend. Host and the TLS server name carry the real domain, so the new server picks the right site and certificate. Apply it with sudo nginx -t && sudo systemctl reload nginx. When the old access log has stayed empty for a few days, take a final backup and remove the migration key. Raise the TTL back to an hour or more, then cancel the old server.

What usually breaks after the move

  • SSH warns that the remote host identification has changed. The hostname now points to a machine with different host keys. That is expected after a migration. Remove the old entry with ssh-keygen -R example.com, as explained in Host key verification failed.
  • Mail is rejected or lands in spam. The new IP needs a PTR record that resolves back to it, and SPF has to list it.
  • Certificate renewal fails. HTTP validation only works once the domain points at the new server. Install the same certbot plugin the old server used (for example python3-certbot-nginx), then run sudo certbot renew --dry-run there after the switch.
  • A partner API or remote database refuses connections. An allowlist still has the old IP. Your inventory from step 1 lists them.
  • Cron jobs never ran. Check with journalctl -u cron --since today and compare against the inventory.

Moving to a VMHeaven VPS

Every VMHeaven KVM plan comes with full root and a Linux image of your choice, including Ubuntu 24.04 and Debian 12 and 13, so every command above works as written. Standard KVM carries the most vCores for the money and suits most web and API servers. Hi-CPU KVM runs on AMD EPYC with fewer vCores per plan, for work that leans on one or two cores, such as game servers. Both are in Amsterdam, and VPS Europe shows which lines Frankfurt has in stock if your new server should stand there. A few panel tools help during a move:

  • Browser VNC console: a screen on the server even when a firewall rule or a network change has cut off SSH.
  • Backups: take one of the new server right before the final sync. A restore rolls it back to that point.
  • Reinstall: if the build went wrong, start over from a clean template. This erases the disk.
  • Reverse DNS: set the PTR record for the new IP in the panel if the server sends mail.
  • Extra storage: if the data outgrows the plan, add disk space in 15 GB blocks to the running server from the panel. It renews with the server and cannot be removed later, so add only what you need.

Frequently asked

How long does a server migration take?

Plan a day or two of preparation, mostly because a lowered DNS TTL needs time to take effect everywhere. The actual downtime is only the final file sync and database dump, often a few minutes for a typical website, and a rehearsal run tells you the real figure in advance.

Can I migrate a server with zero downtime?

Close to it. The old server keeps serving until DNS switches, so read-only traffic never stops. A site that accepts writes needs a short freeze for the final sync, unless you run the new database as a replica of the old one and promote it at cutover.

Should I copy the whole disk image to the new server?

Usually not. A disk image brings the old network configuration, drivers and disk IDs along, which often stops it from booting on another provider's platform. Install a fresh OS on the new server and move data and configuration with rsync and database dumps instead.

Related articles

All guides