What you will set up
This guide moves a running app from a server you rent somewhere else to a new Melonslab server. You copy everything across while the old server keeps working, test the new server under the app's real name before anyone else reaches it, and then switch over in one short stop: stop the app on the old server, copy what changed, start it on the new one, and point DNS at the new address.
The example is Plausible Analytics, set up as in our guides: rootless Podman with one user per app, behind Caddy. The same steps move any app that keeps its data in files and databases on the server.
Every step below was run between two Melonslab VC-P Alloy servers (2 vCPU, 8 GB) with Debian 13, one playing the old server:
- The checklist in step 1 found every service, timer, container, volume, secret and port on the old server.
- The first copy, 1.2 GB with Plausible running, took 17 seconds, and every file kept its owner, including the files the containers own. With the user's ID range changed on purpose, Podman could no longer read the database.
- The new server answered with the same Let's Encrypt certificate as the old one before DNS changed, and logging in to Plausible worked there, with every site and visit.
- Both databases were restored on the new server from dumps taken while the old one ran.
- At the switch, Plausible was unreachable for 70 seconds: 10 to stop it, 5 for the last copy and 48 to start it, plus a mistake in our own script.
- Within four minutes of the DNS change, Cloudflare's and Google's public resolvers gave the new address, and visits to the test website were counted on the new server, with the right country.
- Started again as a rollback, the old server had Plausible back in 49 seconds, with its data as it was at the switch.
The new server used the same memory as the old one, about 1.3 GB with Plausible and Caddy.
Before you start
You need:
- the old server, with root access over SSH with a key;
- a new Melonslab server with Debian 13, with at least as much disk space as the old server uses. Leave swap off when you order it; if your apps need swap, use zram;
- access to the DNS records of every name the old server answers for;
- a quiet moment for the switch in step 9.
The examples use 192.0.2.50 and 2001:db8:50::a for the old server, 203.0.113.10 and 2001:db8:10::a for the new one, and plausible.example.com and www.example.com for the names. Replace them with your own throughout. Commands run as root unless the step says otherwise.
1. Take stock of the old server
Before you copy anything, find out what the old server does. As root on the old server, run these and keep the output:
# Users, and the user ID ranges their containers use
getent passwd | awk -F: '$3 >= 1000 && $3 < 65534 {print $1, $3, $6}'
cat /etc/subuid
# Users whose services start at boot
ls /var/lib/systemd/linger
# System services, timers and cron jobs
systemctl list-units --type=service --state=running --no-pager --no-legend
systemctl list-timers --no-pager --no-legend
ls /etc/cron.d /var/spool/cron/crontabs
# Each user's services, timers, containers, volumes and secrets
for u in $(ls /var/lib/systemd/linger); do
echo "== $u"
systemctl --user -M $u@ list-units --type=service,timer --state=active --no-pager --no-legend
systemd-run -M $u@ --user -qPG --wait sh -c 'podman ps -a --format "{{.Names}} {{.Image}}"; podman volume ls -q; podman secret ls --format "{{.Name}}"'
done
# Ports, firewall and kernel settings
ss -tulpn
ufw status
ls /etc/sysctl.d
# Where the data is
du -sh /home/* /srv /opt /var/lib/docker /var/lib/postgresql /var/lib/mysql 2>/dev/null
# Files that contain the old server's own addresses
grep -rIl -e 192.0.2.50 -e 2001:db8:50: /etc /home /srv 2>/dev/null
On our old server this listed two users, caddy (ID 1000) and plausible (1001), both starting at boot; Caddy and the Plausible pod with four containers, four volumes and three secrets; Plausible's nightly backup timer; ports 80 and 443 open to the internet and 8110 on 127.0.0.1 only; 63 MB under /home/caddy and 1.1 GB under /home/plausible. The only file with the server's address was its network configuration, which stays behind. If the old server runs Docker, docker ps -a and docker volume ls show the same for Docker; we did not test a move from Docker.
From the output, write a short list:
- What runs: each service and timer, and which user runs it.
- Where the data is: with rootless Podman, everything is in the users' home directories: the Quadlet files, the volumes, the images and Podman's secrets. Note anything outside
/hometoo. - Users and ID ranges: the user names, their IDs and their lines in
/etc/subuid. Step 3 needs them. - Ports: the ones in
ufw status, to open on the new server. - Names: every name in Caddy's configuration (
grep -E '^[a-z0-9.-]+ \{' /home/caddy/Caddyfile), each needing its DNS records changed in step 9. - Certificates: Caddy keeps them in its
caddy-datavolume; certbot in/etc/letsencrypt. - Secrets: passwords and keys that must move with the data. Plausible's
SECRET_KEY_BASEis one: without the same value, logins and two-factor codes stop working. Podman's secrets move with the user's home directory. - Settings outside the apps: files you added under
/etc, such as/etc/sysctl.d/50-unprivileged-ports.conffrom the Podman guide. Make these again by hand on the new server. Do not copy/etcas a whole: its network settings, SSH keys and disk layout belong to the old server.
2. Lower the DNS TTL in advance
Each DNS record has a TTL, the number of seconds that resolvers, browsers and operating systems may keep the answer. After you change a record, some visitors keep reaching the old address until their copy expires: with a TTL of 86400, up to a day.
Check your TTLs from the new server:
apt install -y bind9-dnsutils
dig +noall +answer plausible.example.com A plausible.example.com AAAA
plausible.example.com. 3600 IN A 192.0.2.50
plausible.example.com. 3600 IN AAAA 2001:db8:50::a
The second column is the TTL. At your DNS provider, set it to 300 seconds, five minutes, for every name on your list. Do this at least one old TTL before the switch, so that the long copies have expired by then: with 3600, an hour before; with 86400, the day before. Our records had a TTL of 300, and public resolvers gave the new address within two to four minutes of the change.
3. Prepare the new server
Set up the new server the same way as the old one: first secure it, then, for an old server like ours, install Podman as in steps 1 and 2 of the Podman guide. Open the same ports as on the old server, and install rsync, which copies the files. It is needed on both servers:
apt install -y rsync
Create the users in the same order as on the old server, and let their services start at boot. Do not set anything else up for them: their files come with the copy.
for u in caddy plausible; do useradd -m -s /bin/bash $u; loginctl enable-linger $u; done
Each user's containers store their files under a range of user IDs that belongs to that user, as listed in /etc/subuid. The copy keeps the numbers, so the IDs and the ranges must be the same on both servers. Run this on both, and compare:
getent passwd caddy plausible | cut -d: -f1,3,4
grep -E '^(caddy|plausible):' /etc/subuid /etc/subgid
caddy:1000:1000
plausible:1001:1001
/etc/subuid:caddy:100000:65536
/etc/subuid:plausible:165536:65536
/etc/subgid:caddy:100000:65536
/etc/subgid:plausible:165536:65536
Debian hands out IDs and ranges in the order users are created, so the same order gives the same numbers. If they differ, for example because the new server already had another user, delete the new users again with userdel -r and create them with the old IDs (useradd -u 1001 ...), and edit the user's lines in /etc/subuid and /etc/subgid to match the old server, before you copy. If a range overlaps another user's, change that user's range instead. We tested what happens otherwise: with plausible's range moved, Podman could no longer read the database's files (step 5 shows the check).
4. Let the new server log in to the old one
The copy runs on the new server and fetches the files from the old one over SSH, as root, so that it can read every file and keep every owner. Create a key on the new server:
ssh-keygen -t ed25519 -N '' -C migration -f /root/.ssh/id_ed25519
cat /root/.ssh/id_ed25519.pub
On the old server, add that line to the end of /root/.ssh/authorized_keys. Then, from the new server:
ssh root@192.0.2.50 hostname
The first time, SSH shows the old server's key fingerprint. Compare it with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the old server before you answer yes. Step 10 removes the key again.
If your old server is also at Melonslab, in the same network as the new one, the two cannot reach each other until their traffic goes through the gateway: set that up on both first, as in the guide to connecting two servers. Moving from another provider, you do not need that.
5. Copy everything once, while it runs
As root on the new server:
rsync -aHAXS --numeric-ids --info=stats1 root@192.0.2.50:/home/ /home/
-acopies the whole tree with permissions, owners and times;-Hkeeps hard links,-Aaccess lists and-Xextended attributes, which Podman's image storage uses.-Skeeps sparse files sparse, so that a large, mostly empty file does not fill the disk.--numeric-idskeeps the user IDs as numbers. Without it, rsync matches owners by name, and the IDs that containers use have no names.
The first copy takes as long as your data needs to cross the internet; ours, 1.2 GB between two servers in the same data centre, took 17 seconds. Run the same command again any time: it only sends what changed. Add other directories from your list the same way, such as root@192.0.2.50:/srv/ /srv/.
Check that the owners came across. Run this on both servers; the numbers must be the same:
find /home -xdev -printf '%U\n' | sort -n | uniq -c
1 0
1101 1000
18645 1001
1595 165605
943 165636
6 166534
The large numbers are IDs from plausible's range: the files its containers own. To see them as the containers do, as plausible on the new server (machinectl shell plausible@):
podman unshare ls -lnd ~/.local/share/containers/storage/volumes/*/_data
drwxrwxrwx 3 999 65533 4096 Oct 2 18:37 /home/plausible/.local/share/containers/storage/volumes/plausible-data/_data
drwx------ 19 70 70 4096 Oct 2 19:09 /home/plausible/.local/share/containers/storage/volumes/plausible-db/_data
drwxrwsrwx 13 101 101 4096 Oct 2 19:09 /home/plausible/.local/share/containers/storage/volumes/plausible-events/_data
drwxrwxrwx 2 101 101 4096 Oct 2 18:36 /home/plausible/.local/share/containers/storage/volumes/plausible-events-logs/_data
PostgreSQL owns its files as user 70, and ClickHouse as 101. When we moved plausible's range on purpose, the same command said Permission denied, and the database did not start.
Restart the new server once now, with systemctl reboot. The copy also holds Podman's record of which containers were running, and until a restart Podman tries to stop containers that never ran on this server; in our test it failed with conmon exited prematurely. After the restart, Podman treats them as stopped, and the users' services start by themselves, Caddy and the app included.
6. Make the databases consistent
Files copied from a database while it writes may not fit together: in our test, PostgreSQL started from such a copy, but nothing guarantees that it will. There are two ways to a consistent copy:
- Stop the app, and copy again. With the app stopped, its files are complete, and the second copy takes seconds because only changes move. This is the way we recommend for the switch in step 9: the new server gets exactly the old data, with nothing to convert.
- Copy a dump. Each database writes a consistent copy of itself while it runs, and you load it on the new server. Use this to test with real data without stopping the old server, and for the move itself when the new server runs another major version of the database.
For the test in steps 7 and 8, use dumps. The backup in step 10 of the Plausible guide makes one of PostgreSQL and one of ClickHouse. Run it on the old server, as root:
systemctl --user -M plausible@ start plausible-backup.service
Copy it to the new server:
rsync -aHAXS --numeric-ids --delete root@192.0.2.50:/home/plausible/backup/ /home/plausible/backup/
Then load it, as plausible on the new server. This replaces both databases from the copy with empty ones, and loads the dumps into them:
systemctl --user stop plausible-pod
podman volume rm plausible-db plausible-events
systemctl --user start plausible-db plausible-events
podman exec plausible-db createdb -U postgres plausible_db
podman exec -i plausible-db psql -q -U postgres plausible_db < ~/backup/plausible-db.sql
podman exec plausible-events clickhouse-client -q "RESTORE DATABASE plausible_events_db FROM File('events')"
systemctl --user start plausible-app
The restore ends with RESTORED. For another app, use its own dump commands: pg_dump or pg_dumpall for PostgreSQL, mysqldump --single-transaction for MariaDB and MySQL.
7. Check the certificates
Caddy's certificates and its Let's Encrypt account are in its caddy-data volume, which came with /home/caddy. The new Caddy serves them as soon as it starts, so there is no gap while it waits for new ones. Without them, Caddy can only get a certificate once DNS points at it, and the first visitors after the switch would see certificate errors until it has one.
Check from your own computer that the new server serves a valid certificate, with --resolve sending the request to the new address without any DNS change:
curl -sI --resolve plausible.example.com:443:203.0.113.10 https://plausible.example.com/ | head -1
openssl s_client -connect 203.0.113.10:443 -servername plausible.example.com </dev/null 2>/dev/null | openssl x509 -noout -serial -enddate
HTTP/2 200
serial=06D869A8956CDC1FC20E1D40D8494AD90E92
notAfter=Dec 31 15:45:07 2026 GMT
curl fails with a certificate error if the certificate is wrong. Run the openssl line against 192.0.2.50 too: in our test both servers showed the same serial number. Caddy renews certificates 30 days before they expire, which works from the new server once DNS points at it; our certificate was too new to test a renewal.
8. Test the new server before anyone uses it
Use the app on the new server under its real name. For a browser, add the new address to the hosts file on your computer, /etc/hosts on Linux and macOS and C:\Windows\System32\drivers\etc\hosts on Windows:
203.0.113.10 plausible.example.com www.example.com
Your computer now goes to the new server for these names, and everyone else still to the old one. Log in, and click through what you use. In Plausible, our account logged in with its existing password, and the dashboard showed the site with all its visits, so SECRET_KEY_BASE and both databases had moved. We tested with Chromium's built-in equivalent of a hosts file, --host-resolver-rules.
Anything you change during the test is overwritten by the last copy in step 9, so test freely. Remove the line from the hosts file when you are done.
While both servers run, both run their timers too. Apps that send email or call other services on a schedule may do it twice: stop those on the new server until the switch, or keep the test short.
9. Switch over
Pick a quiet moment. Each of these steps is a command or two; ours took 70 seconds from the old Plausible's last answer to the new one's first.
On the new server, stop the app, as the last copy replaces its files:
systemctl --user -M plausible@ stop plausible-pod
On the old server, stop it, and keep it from starting again at boot:
systemctl --user -M plausible@ stop plausible-pod
loginctl disable-linger plausible
Leave Caddy running on the old server. Visitors whose DNS still has the old address then get an error page from it for a few minutes, instead of no answer at all. Plausible saves the visits it holds in memory before it stops, so none are lost there.
On the new server, copy what changed, and start the app:
rsync -aHAXS --numeric-ids --delete --info=stats1 root@192.0.2.50:/home/plausible/ /home/plausible/
systemctl --user -M plausible@ start plausible-pod plausible-app
until curl -sf -o /dev/null --resolve plausible.example.com:443:127.0.0.1 https://plausible.example.com/api/system/health/ready; do sleep 1; done; echo ready
--delete removes files that are no longer on the old server, such as everything left from the test. Our copy took 5 seconds and the start 48. Copy /home/caddy again only if you changed Caddy's configuration on the old server since step 5, and stop Caddy on the new server first.
Now change the A and AAAA records of every name on your list to the new addresses, 203.0.113.10 and 2001:db8:10::a. Watch the public resolvers follow:
dig +short plausible.example.com A @1.1.1.1
dig +short plausible.example.com A @8.8.8.8
When both give the new address, visit your website in a normal browser, without the hosts file line. The visit appears in Plausible's dashboard on the new server within seconds. In our test, Cloudflare gave the new address after two minutes, Google after four, and the first visit after that was counted on the new server, from Sweden. To see who still reaches the old server, look at its Caddy log; Caddy logs every request it cannot pass on, with the visitor's address:
journalctl -f CONTAINER_NAME=caddy
10. After the move
Keep the old server for a few days, with the app stopped but its data intact: it is your way back (step 11). Before you cancel it, check:
- Backups run from the new server, and include the copied data. If the old server backed up somewhere off-site, as with restic, set it up on the new server, and stop it on the old one, or it keeps sending old data. User timers stopped with
disable-lingerin step 9; system timers and cron jobs on the old server you stop yourself. - Monitoring checks the new server. Checks by name follow DNS by themselves; checks by IP address need the new one.
- Email: if the server sends email, set reverse DNS for its new addresses, as in step 2 of the mail server guide, and replace the old addresses in your SPF record.
- Firewall rules elsewhere that allow the old server's address, such as at a database or backup server, now need the new one.
- The SSH key from step 4. Remove it from the old server, and delete it on the new one:
sed -i '/ migration$/d' /root/.ssh/authorized_keys # on the old server
rm /root/.ssh/id_ed25519 /root/.ssh/id_ed25519.pub # on the new server
When everything has run on the new server for a few days, set the TTLs back to what they were, and cancel the old server.
11. If something is wrong: go back
Before you change DNS, going back costs nothing: start the app on the old server again, and visitors never noticed. As root on the old server:
loginctl enable-linger plausible
This starts the user's services as at boot. In our test, Plausible answered again 49 seconds later, with its data as it was at the switch.
After the DNS change, do the same, and change the DNS records back to the old addresses. Anything the new server collected since the switch, such as new visits, is then only on the new server. To keep it, stop the app on the new server and copy its files back the other way, with the same rsync command run on the old server; we did not test the way back with data.
Troubleshooting
podman unshare ls says Permission denied, or PostgreSQL's log says could not open file "global/pg_filenode.map": Permission denied. The user's ID range on the new server differs from the old one, so the containers do not own their files. Make the lines in /etc/subuid and /etc/subgid match the old server (step 3), then run podman system migrate as the user. Do not chown the files as root: that gives them owners the containers cannot use, and repairing it means podman unshare chown for every volume.
A service does not start after the first copy, and its log says conmon exited prematurely or that a container name is already in use. Podman has its record of containers that were running on the old server. Restart the new server (step 5).
rsync or SSH says No route to host between two Melonslab servers. They are in the same network, and their traffic does not go through the gateway yet. See the guide to connecting two servers.
curl in step 7 fails with a certificate error. Caddy on the new server has no certificate for the name, because /home/caddy was not copied, or not completely. Stop Caddy on the new server, copy /home/caddy again, and start it.
Visitors still reach the old server long after the change. A TTL was still long when you changed the record: wait one old TTL. Some programs keep DNS answers for longer than they should; restarting them helps.