How to Migrate a Server to a New VPS
Move a live server to a new VPS as a rehearsed cutover: inventory, rebuild instead of clone, dump the database, lower DNS TTL early, verify, then switch.
Migrate a server to a new VPS as a rehearsed cutover
To migrate a server to a new VPS, treat the move as a rehearsed cutover instead of a copy. Build the new box from scratch, sync the data twice, prove the new box works on its own IP address before you touch DNS, then switch the records and leave the old box running until you are certain. Copying the bytes is the easy part. The order of operations decides whether the move is uneventful or expensive.
This guide covers one Linux server running a web application, a database and a TLS (transport layer security) certificate. That covers most single-server setups. Two hosts are involved, so every example says in a comment which host it runs on. The addresses come from the documentation ranges: 198.51.100.10 is the old server, 203.0.113.20 is the new one.
Read the whole runbook before you start. The first step, lowering the DNS TTL, has to happen days before the step you actually care about.
Take the inventory before you build anything
You cannot rebuild a server you have not described. Spend an hour writing down what the old box does, because the thing that breaks after a migration is always the thing nobody remembered: a cron job, a firewall exception, an environment file sitting outside the application directory.
Run these on the old server and keep the output somewhere you can read it from the new one.
# old server: packages you asked for, not the dependencies they pulled in
apt-mark showmanual > ~/inv-packages.txt
# old server: what runs now, and what starts at boot
systemctl list-units --type=service --state=running --no-pager > ~/inv-services.txt
systemctl list-unit-files --state=enabled --no-pager >> ~/inv-services.txt
systemctl list-timers --all --no-pager > ~/inv-timers.txt
# old server: what is listening, on which address and port
sudo ss -tulpn > ~/inv-ports.txt
# old server: human accounts, skipping the system ones
awk -F: '$3 >= 1000 && $3 < 65534 {print $1, $3, $6, $7}' /etc/passwdapt-mark showmanual is the list worth having because it drops everything that arrived as a dependency. A full dpkg --get-selections on a five year old box returns two thousand lines and tells you nothing about intent.
Scheduled work hides in two places, so check both. A job that only runs monthly is the one you will discover six weeks after the migration.
# old server: per-user crontabs, then the system drop-ins
for u in $(cut -d: -f1 /etc/passwd); do sudo crontab -lu "$u" 2>/dev/null | sed "s/^/$u: /"; done
sudo ls -la /etc/cron.d /etc/cron.daily /etc/cron.hourlyThen the parts that are not ordinary files: firewall rules, certificates, databases, and how much data you are actually moving.
# old server
sudo ufw status verbose # or: sudo nft list ruleset
sudo certbot certificates
sudo -u postgres psql -c '\l' # or: sudo mysql -e 'SHOW DATABASES;'
sudo du -xh --max-depth=1 / | sort -hcertbot certificates prints each certificate name, the domains it covers, its expiry date and the paths of the files on disk. That output is your TLS checklist. du -x stays on one filesystem, so it will not walk into a mounted backup volume and report a number ten times too large.
Two items live outside the server and get forgotten every time. First, any third party that allowlists your server's IP address: a payment gateway, a managed database, an SMTP relay, a partner API. The new box has a new address, so those allowlists need the new IP added before the cutover, not after. Second, DNS records you did not create yourself, such as an MX record or an SPF record that names the old IP in text.
Why you rebuild instead of cloning the old root filesystem
Cloning a whole root filesystem onto the new VPS looks faster, and it is, until it is not. A root filesystem that has been in production for years carries hand-edited config nobody documented, packages from a repository that no longer exists, and a boot setup built for the old platform's virtual hardware. You import all of it, including the reason you were migrating.
Rebuilding is slower on day one and cheaper every day after. You install the current release, apply your base hardening, then copy data only: the application directory, the site configs, the database dump, the certificates, the user uploads. Anything you cannot explain does not come across. Start the new box the way you would start any box, with the first ten minutes on a new VPS, then add services from the inventory one at a time and confirm each one before adding the next.
When an image or snapshot restore is the right call
There is one honest exception to rebuilding. If the old server will not boot, or the application is one nobody can rebuild from source any more, a provider image or snapshot restore is the pragmatic answer. It has real limits: it works inside one provider, often only within one plan family, because the restored disk expects that platform's virtual devices and network naming.
A snapshot of a running box also carries the same consistency problem as any other file-level copy of a live database. Treat image restore as a recovery route rather than a migration plan, and read why a snapshot is not the same thing as a backup before you build a plan on top of one.
How the files move: rsync over SSH
Run rsync from the old server, pushing to the new one. Pushing is usually simpler, because the old server already has the data and can read all of it under sudo.
# old server: dry run first, and read what it says it will do
sudo rsync -aHAX --dry-run --itemize-changes \
-e 'ssh -i /root/.ssh/id_ed25519' \
/srv/app/ deploy@203.0.113.20:/srv/app/
# old server: the real bulk pass
sudo rsync -aHAX --info=progress2 \
-e 'ssh -i /root/.ssh/id_ed25519' \
/srv/app/ deploy@203.0.113.20:/srv/app/The flags matter. -a preserves permissions, timestamps, symbolic links and ownership. -H keeps hard links as hard links instead of expanding them into separate copies. -A copies POSIX ACLs (access control lists) and -X copies extended attributes. Without those last two, a file that looks identical can behave differently, because SELinux labels and ACLs live in extended attributes and nothing else records them.
Two details cause most of the failures here.
The trailing slash decides where the data lands. /srv/app/ means the contents of that directory. /srv/app means the directory itself. Get it wrong and you end up with /srv/app/app on the new server, and the application starts and then reports missing files, because the paths it was configured with are now one level too shallow.
Under sudo, the tilde is root's home directory. Writing -e 'ssh -i ~/.ssh/id_ed25519' inside a sudo rsync looks for the key in /root/.ssh, not in your own home directory. If the key is not there, SSH prints Permission denied (publickey), rsync prints rsync: connection unexpectedly closed and exits non-zero. Write the key path in full. If that authentication message keeps appearing after you fix the path, the publickey failure has a short list of causes and directory permissions on the new server are the next one to check.
Ownership needs one decision. Running as root, rsync maps owner and group by name by default, so a file owned by www-data on the old box becomes owned by www-data on the new box even though the numeric UID (user ID) differs. That is what you want for a rebuild. Add --numeric-ids only when you are copying a filesystem whose accounts do not exist on the target, and then check the result with ls -ln, because a file owned by a UID with no matching account shows as a bare number and every service that reads it is denied.
Run the bulk pass days ahead, while the old server is still serving traffic. Repeat it as often as you like: rsync sends only what changed, so the second pass takes minutes instead of hours. The final pass, inside the cutover window, adds --delete so files removed on the old server also disappear on the new one.
# old server: final pass, inside the window, after the app has stopped writing
sudo rsync -aHAX --delete --info=progress2 \
-e 'ssh -i /root/.ssh/id_ed25519' \
/srv/app/ deploy@203.0.113.20:/srv/app/--delete removes files on the destination that are gone from the source, so a wrong source path plus --delete empties the destination directory. Run it with --dry-run first, every single time. Long transfers also die when your laptop's SSH session drops, so start them inside tmux or screen on the old server. Add --bwlimit=20M if the copy saturates the link while the old server is still serving users.
How the database moves: a native dump
A database is not a directory of files, even though it looks like one. It is a set of files plus in-memory state plus a write-ahead log, consistent only at the instants the database itself defines. Use its own tool.
PostgreSQL needs two dumps, because roles are cluster-wide and pg_dump does not include them:
# old server
sudo -u postgres pg_dumpall --globals-only -f /var/backups/globals.sql
sudo -u postgres pg_dump -Fc -f /var/backups/appdb.dump appdb# new server: restore in this order
sudo -u postgres psql -f /var/backups/globals.sql
sudo -u postgres createdb -O appuser appdb
sudo -u postgres pg_restore -d appdb --no-owner /var/backups/appdb.dumpSkip globals.sql and you get every table restored and no application role able to read them, because the GRANT statements refer to a user that does not exist. -Fc writes the custom archive format, which only pg_restore reads and which lets you restore selected tables later. Restore into the same major version or a newer one. Going backwards, from 17 to 16 for example, is not supported, and pg_restore rejects the archive with an unsupported-version error in the file header before it writes anything.
MySQL and MariaDB use one command, with four options that are not defaults:
# old server
sudo mysqldump --single-transaction --routines --triggers --events \
--databases appdb > /var/backups/appdb.sql# new server
sudo mysql < /var/backups/appdb.sql--single-transaction takes a consistent snapshot without blocking writers, but only for InnoDB tables. A MyISAM table in the same database is copied with no such guarantee, so check your storage engines before you trust the dump. --routines, --triggers and --events are off by default, which means a plain dump restores your data and silently leaves behind your stored procedures and scheduled events. Database users and their grants live in the mysql system database, which a --databases appdb dump never touches, so recreate them on the new server with CREATE USER and GRANT. MariaDB 11 ships the same tool as mariadb-dump and keeps mysqldump as a symbolic link, so either name works as of August 2026.
SQLite is a single file, and copying it while the application writes gives you a torn file. It has its own safe path:
# old server
sqlite3 /var/lib/app/app.db ".backup '/var/backups/app.db'"Whatever the engine, check the dump before you trust it. A dump that stopped early because the disk filled will restore without complaint, right up to the point where it was truncated.
# old server: a complete mysqldump ends with a line reading "-- Dump completed on ..."
tail -n 3 /var/backups/appdb.sql
# new server: after restore, count rows in a table whose size you know
sudo mysql -e 'SELECT COUNT(*) FROM appdb.orders;'Why you cannot rsync a running database
rsync copies file by file. A running database writes to several files at once, so by the time rsync reaches the last file, the first one is already out of date. The copy holds pages from different moments, which is a state the database never had. The result is either a server that refuses to start, or the worse case: one that starts, serves correct answers for a week, and then fails when a query finally reaches the damaged page. There is no warning in between.
There are two safe ways to move the files themselves. Stop the database, copy, start it again: correct, simple, and it costs you the length of the copy in downtime. Or use the tool built for a physical copy of a running server. For PostgreSQL that is pg_basebackup, which coordinates with the server so the copy is consistent:
# new server: pull a physical copy from the old one
pg_basebackup -h 198.51.100.10 -U replicator -D /var/lib/postgresql/16/main -X stream -PThat needs a role with the REPLICATION attribute and a matching pg_hba.conf entry on the old server, so it is more setup than a dump. It is worth it when the database is large enough that a dump and restore does not fit inside your window. For a normal single-server migration, the dump wins.
Rebuild the certificates before the cutover, not after
A TLS certificate is tied to the domain name, not to the IP address, so the certificate file itself moves without trouble. What does not move cleanly is renewal. Certbot's default HTTP-01 challenge asks the certificate authority to fetch a file over port 80 at the name being certified, and until DNS points at the new server, that fetch lands on the old one and the new server's renewal fails.
The first option is to copy the existing certificates and their renewal state. They stay valid until their expiry date, whichever server holds them.
# old server
sudo rsync -aHAX -e 'ssh -i /root/.ssh/id_ed25519' \
/etc/letsencrypt/ root@203.0.113.20:/etc/letsencrypt/Each file under /etc/letsencrypt/renewal/ names the authenticator plugin that issued the certificate, so install the same plugin on the new server (python3-certbot-nginx, for example) or the first renewal fails with a message about an unknown authenticator. Prove renewal works before you depend on it:
# new server, after DNS has moved
sudo certbot renew --dry-runThe second option is to issue a fresh certificate on the new server using the DNS-01 challenge, which proves control through a TXT record and never touches port 80. That works before the migration, while the name still resolves to the old server, which makes it the cleaner choice if you can automate your DNS provider. Issuing certificates with the DNS-01 challenge covers the plugin and credential setup.
Either way, check what the new server actually presents, without changing DNS:
# your laptop
echo | openssl s_client -connect 203.0.113.20:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -dates -issuer-servername sends SNI (server name indication), which is what makes the web server choose the right virtual host. Leave it out and you get the default certificate for that IP and a mismatch that looks like a real problem but is not.
Lower the DNS TTL days before the cutover
DNS is where a careful migration still goes wrong, because the delay is built in and you cannot shorten it on the day. A resolver that has cached your A record keeps serving it for the length of the TTL (time to live) it was handed. Lowering the TTL now does nothing for a resolver that cached the record ten minutes ago at the old value: it holds the old value for the rest of the old TTL, and only then learns the new, shorter one. So lower the TTL at least one full old-TTL period ahead of the cutover. A day ahead is the comfortable version. If the moving parts here are new to you, the walkthrough of records, resolvers and caching is the background.
The numbers below are arithmetic from the TTL itself, not a measurement.
The data behind this chart
[
{
"label": "TTL 3600 s (common default)",
"ttl_seconds": 3600,
"worst_case_stale_minutes": 60
},
{
"label": "TTL 900 s",
"ttl_seconds": 900,
"worst_case_stale_minutes": 15
},
{
"label": "TTL 300 s (lowered for cutover)",
"ttl_seconds": 300,
"worst_case_stale_minutes": 5
},
{
"label": "TTL 60 s (short window)",
"ttl_seconds": 60,
"worst_case_stale_minutes": 1
}
]A record published with a TTL of 3600 seconds can keep sending users to the old IP for 60 minutes after you change it. Lower it to 300 seconds and that worst case falls to 5 minutes. Treat those figures as a floor rather than a promise. Some resolvers apply a minimum TTL of their own and ignore anything shorter, and some application runtimes cache a resolved address for the life of the process, so a client that started before your change may never look again until it restarts.
Read the authoritative answer, not your own cache, when you check that the lower TTL is live:
# your laptop: ask the zone's own nameserver, so no cache is involved
dig +short NS example.com
dig +noall +answer @$(dig +short NS example.com | head -n1) example.com AThe second field of that answer line is the TTL in seconds. Then work through the records people forget: the AAAA record if the old server had IPv6, the www name when it is a separate A record rather than a CNAME, any MX record pointing at the server itself, an SPF record that lists the old IP, and the reverse DNS (PTR) record on the new address. Set the PTR through your provider's control panel before the cutover if the server sends mail, because receiving mail servers check it and a missing PTR turns into rejected mail hours after everything else looked fine.
Verify the new server on its IP before you touch DNS
You can test the whole application on the new server while DNS still points at the old one. Override the name lookup for one request:
# your laptop: force one name to the new IP, for this request only
curl -sS --resolve example.com:443:203.0.113.20 \
-o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://example.com/--resolve changes only where the connection goes. The TLS certificate is still checked against the real name, so this proves the certificate as well as the service. %{ssl_verify_result} prints 0 when the chain verified.
For clicking through the site in a browser, override the name for your whole machine by adding one line to /etc/hosts on your laptop, or to C:\Windows\System32\drivers\etc\hosts on Windows:
203.0.113.20 example.com www.example.comThen walk the application the way a user would. Log in. Load a page that reads from the database. Submit a form that writes to it. Upload a file and confirm it lands on disk. Trigger whatever sends email, and check it arrives, because outbound SMTP from a fresh IP is a common surprise. Remove the hosts line as soon as you are done. Leaving it in place is how you spend an hour debugging a site that everybody else can see perfectly well.
The cutover, step by step
- Days ahead: lower the TTL, run the bulk rsync, build the new server and test it behind a hosts override.
- Day of, before the window: add the new IP to every third-party allowlist, and confirm the new server's backup job is configured and pointed at your repository.
- Open the window: put the application into maintenance mode on the old server so it stops accepting writes.
- Take the final database dump, then run the final rsync pass with
--delete. - Restore the dump on the new server and start the services.
- Test again through
--resolveand the hosts override, including one real write. - Change the A and AAAA records to the new IP.
- Watch both servers. The old server's access log shows who is still arriving there, and the number should fall towards zero over the TTL.
- Take the maintenance page down.
- Leave the old server running and untouched for at least a week.
The maintenance mode step is the one people skip, and it is the one that protects you. Once the new database has accepted a write, rolling back means either losing that write or dumping the new database and loading it back into the old one. A read-only window of a few minutes is cheap. Two databases that have both taken writes is days of manual reconciliation.
The rollback plan
Rollback is one action: change the DNS records back to 198.51.100.10. It only works because of four things you did earlier.
- The old server is still running with its services up and its data intact. You stopped writes there, you did not decommission it.
- The TTL is still low, so the way back is as fast as the way forward was.
- You added the new IP to third-party allowlists rather than replacing the old one. Remove the old address and your rollback path fails at the payment gateway.
- The new server has taken no writes you cannot identify, because the only writes so far were your own test transactions.
Decide before the window opens what triggers a rollback. Two triggers are enough: any error you cannot diagnose within a fixed number of minutes, and any data loss at all. Writing them down in advance is what prevents the hour of guessing that turns a ten minute outage into a long one.
Prove the migration worked
A migration is not finished when the site loads. Check the things that only fail later.
# new server
systemctl --failed
journalctl -p err -b --no-pager | tail -n 40
sudo certbot certificates
sudo systemctl list-timers --all --no-pagersystemctl --failed reporting 0 loaded units listed is the result you want. certbot certificates should show the expiry dates you expect, and list-timers should show every scheduled job from your inventory with a real next-run time, not a blank.
Then reboot the new server once, on purpose, while you are watching. A service that someone started by hand and never enabled works perfectly until the first unplanned reboot at three in the morning.
# new server
sudo reboot
# your laptop, once it comes back
curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/If the application runs in containers, the same trap has a different shape, because a compose stack needs an explicit restart policy to come back after a reboot.
The last check is the easiest to postpone and the most important: the backup job. A migration that ends with an unbacked-up server has traded one risk for another. Run the backup on the new box by hand, then restore a single file from it into a temporary directory. A restic repository with a restore you have actually tested is the version of this that helps when you need it. If you are running the old and new servers side by side for a week, a consistent way to reach and configure each host stops the two from drifting apart while both are live.
After the cutover: the old server and the last items
Keep the old server for one to two weeks. It costs one month of a plan you were about to cancel, and it is the only rollback you have. Then close out the rest.
- Reusing the same hostname in your
~/.ssh/configfor the new box gives youWARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!on the first connection, because that name now answers with a different host key. Clear the stale entry withssh-keygen -R example.comonce you are sure why it changed, and not reflexively, since the same warning is what an interception attack looks like. A migration is also a good moment to review which keys can reach what, which is what SSH key management on a small fleet is for. - Take one final snapshot or backup of the old server, and store it somewhere that is not the old provider.
- Remove the old IP from monitoring checks, from SPF records, and from third-party allowlists, in that order and last of all.
- Cancel the old plan only after that final copy is confirmed readable somewhere else.
FAQ
How long does it take to migrate a server to a new VPS?
The user-visible outage is usually the final database dump, the final rsync pass and the service start, so ten to thirty minutes for a small application. The calendar time is longer, because the DNS TTL has to be lowered at least one old-TTL period before the switch and a day ahead is safer. Plan the bulk data copy days early too. It runs against a live server, and repeating it later only transfers what changed since the last pass.
Can I rsync a running MySQL or PostgreSQL database instead of dumping it?
No. rsync copies file by file while the database writes to several files at once, so the copy contains pages from different moments and represents a state the database never had. It may refuse to start, or start and fail later when a query reaches a damaged page. Use pg_dump with pg_dumpall --globals-only, or mysqldump --single-transaction, or stop the database first and then copy the files. For a large PostgreSQL cluster, pg_basebackup makes a consistent physical copy of a running server.
How do I test the new VPS before I change DNS?
Override the name lookup on your own machine. For a single request, curl --resolve example.com:443:203.0.113.20 https://example.com/ sends the connection to the new IP while still checking the certificate against the real name. For browser testing, add 203.0.113.20 example.com to /etc/hosts on your laptop, click through a login, a database read, a form write and a file upload, then remove the line. To inspect the certificate alone, run openssl s_client -connect 203.0.113.20:443 -servername example.com.
What TTL should I set, and when should I lower it?
Lower the A and AAAA records to 300 seconds, and do it at least one full old-TTL period before the cutover. A resolver that cached the record before your change keeps the old value for the remainder of the old TTL, so lowering it an hour early achieves nothing if the old TTL was 86400. Raise it back to your normal value a few days after the migration, once the old server's access log has gone quiet.
Should I copy the TLS certificate or issue a new one on the new server?
Either works. Copying /etc/letsencrypt/ keeps the certificate valid to its existing expiry, but you must install the same certbot authenticator plugin on the new server or the first renewal fails, so run certbot renew --dry-run after the DNS switch to confirm. Issuing fresh is cleaner when you can use the DNS-01 challenge, because it proves control through a TXT record and works before DNS points at the new server. The HTTP-01 challenge cannot be used on the new server until DNS has moved, since the validation request would reach the old one.