Guide
Moving a self-hosted service to a new machine
Most failed migrations are not failed copies. They are correct copies that land with the wrong file ownership, on the wrong CPU architecture, next to a newer database, or without the one secret that makes the data readable.
How do you move a self-hosted service to a new server without losing data?
Work in six steps: inventory everything the service touches, stop it and take an export rather than a live copy, move the data preserving numeric ownership, verify on the new host with DNS still pointing at the old one, cut over DNS after lowering the TTL, then leave the old machine powered off but intact for two weeks. The failures that actually happen are UID and GID mismatches making restored files unreadable, a database major version that will not accept the dump, and an application encryption key that was never in the backup.
This is a procedure, not an essay. Follow it in order and a service move takes an evening. Skip step one and you will find out at 2am which of the fifteen things you did not write down actually mattered.
Step 1: inventory#
You are not moving a container. You are moving a container, its volumes, its environment, its ports, its ownership, its scheduled jobs, its DNS name, its certificate state and its secrets. Write all of it down before you touch anything.
# The fully resolved compose file, with every variable expanded
docker compose config > /srv/migration/compose-resolved.yml
# Volumes and their real paths on disk
docker compose ps -q | xargs -r docker inspect \
-f '{{.Name}}{{range .Mounts}} {{.Type}}:{{.Source}}->{{.Destination}}{{end}}'
# Published ports, so you find the collision before the new host does
docker compose ps --format '{{.Service}} {{.Ports}}'
# Numeric ownership inside each data directory. This is the one people skip.
find /srv/appdata -maxdepth 2 -printf '%U %G %p\n' | sort -u | head -40
# Anything outside Docker that touches this service
crontab -l; systemctl list-timers --all | grep -i -E 'backup|app-name'Then add the things no command will tell you: which DNS records point here, which other services call this one by hostname or IP, which API tokens live in another app's config, and which mobile apps have this server's address saved in them.
Stack planner is useful here for the ports and memory side of the new host.
Step 2: stop and export#
Do not copy a running service. The database consistency guide covers why in detail; the summary is that a live copy of a database restores corrupt.
# Stop the app but leave its database up so you can dump it
docker compose stop app worker
# Prefer the project's own exporter when there is one
docker compose exec -T webserver document_exporter ../export
# Otherwise dump the database explicitly
docker compose exec -T db pg_dump -U app -d app -Fc > /srv/migration/app.dump
docker compose exec -T db pg_dumpall -U postgres --globals-only \
> /srv/migration/globals.sql
# Now stop everything and archive the volumes with ownership intact
docker compose down
tar --numeric-owner -C /srv/appdata -czf /srv/migration/appdata.tar.gz .--numeric-owner is not optional. Without it, tar records user names and resolves them against the new host's /etc/passwd, which is how a restore silently reassigns every file to a different account.
Use the project's own export where one exists. Paperless-ngx's document_exporter produces originals plus a manifest.json with every tag, correspondent and permission, and it is the only artifact the project promises to be able to import. The same document notes that an export generally restores into the same version it came from, so do not upgrade and migrate in one move.
Step 3: move, preserving numeric ownership#
# Direct copy, ownership and extended attributes preserved by number
rsync -aHAX --numeric-ids --info=progress2 \
/srv/appdata/ newhost:/srv/appdata/
# Or restore from the off-site repository instead, and prove the backup works
restic -r rest:https://backup.example.net/homelab \
restore latest --target / --include /srv/appdataThe second form is better. A migration is the ideal moment to do the restore rehearsal covered in the backup guide below, because the old machine is still sitting there working if it fails.
The UID and GID problem, which is the one that actually gets you#
This is the most common real failure in a service move and it produces confusing symptoms: the app starts, then reports that it cannot write its database, or shows an empty library, or throws permission errors on a directory that visibly exists.
Containers store numeric IDs. A file owned by UID 911 on the old host is owned by UID 911 on the new one, and if 911 is a different account there (or does not exist), the container's process cannot read it.
# What owns the data now?
stat -c '%u:%g %n' /srv/appdata/*
# What does the container actually run as?
docker compose run --rm --entrypoint id app
# Fix it deliberately, once, before first start
chown -R 1000:1000 /srv/appdata/noderedThree specific traps:
- linuxserver.io images take
PUIDandPGIDenvironment variables. If the old host hadPUID=1000and you copied the compose file but the new host's first user is1001, nothing matches. - Node-RED requires its
/datavolume to be owned by uid 1000 or the container cannot write its own flows. This is documented and still catches people every week. - Rootless Podman maps container UIDs into a subordinate range on the host, so a
chown 1000that is correct under Docker is wrong under Podman. If you are switching runtime and host at once, do them as two separate moves.
Step 4: verify, with DNS still pointing at the old host#
Start the new instance on the new machine and reach it by IP or by a temporary hostname. Do not cut DNS yet.
# Bring it up and watch for the failures that only appear at start
docker compose up -d && docker compose logs -f --tail=100
# Restore the database into the new (probably newer) engine
docker compose exec -T db psql -U postgres -c 'CREATE DATABASE app;'
docker compose exec -T db pg_restore -U postgres -d app --clean --if-exists \
--no-owner < /srv/migration/app.dump
# Prove it with data, not with an HTTP 200
curl -sS -o /dev/null -w '%{http_code}\n' http://10.0.0.30:8080/Verification means logging in, opening a real record, uploading something, and checking one scheduled job. A container that is "up" tells you nothing.
Database version mismatches#
A pg_dump restores into a newer PostgreSQL major version, which is the direction you almost always need. It is not guaranteed to restore into an older one, and pg_dump refuses to dump from a server newer than itself. Copying the Postgres data directory between majors does not work at all; that needs pg_upgrade.
Application versions have their own rules and they are stricter. Nextcloud cannot skip a major version, so a new host pulling latest two releases ahead of your old install will fail the upgrade and you will be restoring. Paperless-ngx exports restore into the version that made them. Immich does not support downgrades at all. Pin the image tag on the new host to the exact version the old host was running. Migrate first, upgrade second, on different days.
Architecture and CPU baselines#
95.2 percent of tools in this index publish official arm64 builds, so an x86-64 to arm64 move is usually uneventful. The exceptions here are BorgBackup (distributions package it, but there is no official arm64 binary on the releases page, so you install from your distro or pip), Harbor, Mattermost, and the NAS operating systems TrueNAS Community Edition and Unraid, which are tied to their own hardware assumptions.
Two related gotchas that are not about arm64 at all. Immich's machine learning container needs an x86-64-v2 CPU, so moving onto older enterprise hardware can break search while everything else works. And several projects have dropped 32-bit ARM: Nginx Proxy Manager ended armv7 support at 2.13.7, and wg-easy builds only x86-64 and arm64 since v15. A Pi 3 on a 32-bit OS is not a migration target.
Step 5: cut over DNS#
Lower the TTL to 300 seconds at least one full old-TTL period before the move, so a record on the default 3600 needs the change made an hour ahead, and realistically a day. Then flip the record, confirm from more than one resolver, and put the TTL back.
dig +noall +answer app.example.com @1.1.1.1
dig +noall +answer app.example.com @192.168.1.53If you run split horizon, remember there are two records to change. DNS for self-hosters covers the resolver side, including the rebinding protection that will make the internal answer disappear.
Certificate and hostname state deserves its own pass. Reissuing ACME certificates on the new host is easier than moving them, but move the ACME account state if you can, because it saves you against rate limits while you test. Beyond the proxy, plenty of services embed their own hostname and will misbehave until you update it: Vaultwarden's DOMAIN, Headscale's server_url (which must exactly match what clients dial), and every OIDC redirect URI registered with your identity provider.
Step 6: keep the old machine cold for a fortnight#
Power it off. Do not wipe it, do not repurpose the disks, and do not leave it running.
Powered off is the important detail. A machine still running the same stack will keep writing to a shared database, keep answering on its old IP for anything with a cached record, and keep trying to renew certificates for a name that has moved. Two services quietly writing to one database is a much worse day than a migration that failed cleanly.
Two weeks covers the jobs you forgot existed: the weekly report, the Sunday-only cron, the monthly integration renewal.
The keys that make a perfect restore useless#
Every one of these is small, none of them lives in the database, and each one turns a complete restore into a rebuild.
| Service | What to bring | What happens without it |
|---|---|---|
| n8n | N8N_ENCRYPTION_KEY, or the whole /home/node/.n8n folder | Starts fine, shows every workflow, cannot decrypt a single credential. A new key is generated silently. |
| Node-RED | credentialSecret from settings.js, or .config.runtime.json | Flows stop with a credentials error and every MQTT password and API token is gone. |
| Actual Budget | The end-to-end encryption password | The budget blobs in user-files/ are unrecoverable. The server login password can be reset; this one cannot. |
| Vaultwarden | ADMIN_TOKEN, config.json, rsa_key* | Admin page locked out and existing auth tokens invalidated. |
| BookStack | APP_KEY from .env | Multi-factor secrets and other encrypted values are unreadable. |
| restic / BorgBackup | The repository passphrase or keyfile | The backup you are restoring from does not open. There is no recovery path. |
Add to that list the ones specific to your stack: Headscale's noise_private.key (lose it and every node re-registers), Syncthing's config.xml and device key (lose them and the device ID changes, so every peer must re-pair), and DNSSEC private keys if you sign a zone.
The checklist#
Copy this into your notes and tick it.
- [ ] Resolved compose file, env files and volume paths captured
- [ ] Numeric UID and GID of every data directory recorded
- [ ] Published ports checked against what already runs on the new host
- [ ] Cron jobs, systemd timers and external callers listed
- [ ] Image tags pinned to the exact versions currently running
- [ ] Encryption keys and admin tokens copied into the password manager
- [ ] App stopped, project exporter run or database dumped
- [ ] Data moved with
--numeric-idsor--numeric-owner - [ ] New host started, database restored, real data verified by logging in
- [ ] One scheduled job observed running successfully
- [ ] DNS TTL lowered ahead of time, both horizons updated
- [ ] Certificates valid on the new host, hostname settings updated in the app
- [ ] Backups reconfigured and one successful run completed from the new host
- [ ] Monitoring and alerting repointed
- [ ] Old machine powered off, disks intact, calendar reminder set for 14 days
That last line is the one that saves you. The backup job is the single most commonly forgotten step in a migration, and the failure mode is that you discover it a month later when you need the backup that was never taken.
What to do next#
If this move is also your first real restore, do it properly: Backups that actually restore covers the rehearsal and the number you should write down afterwards. For the dump commands per engine, Backing up a running database has the exact syntax for Postgres, MariaDB and SQLite inside Docker. And once the new machine is settled, An update strategy that does not lose data covers upgrading it without repeating this whole exercise.
Questions#
Why are all my files owned by the wrong user on the new machine?
Containers store numeric UIDs, not names. If abc was 911 on the old host and 1000 on the new one, every restored file has the wrong owner and the app fails with permission errors that look like a bug. Copy with rsync -aHAX --numeric-ids or tar --numeric-owner so the numbers survive, then either recreate the same numeric user on the new host or chown -R once, deliberately. Check with stat -c '%u %g %n' before you start the container.
Can I move from x86-64 to a Raspberry Pi or an ARM mini PC?
Usually. 95.2 percent of the tools profiled here publish official arm64 builds. The exceptions in this index are BorgBackup (packaged by distributions but with no official arm64 binary on the releases page), Harbor, Mattermost, and the two NAS operating systems TrueNAS Community Edition and Unraid. Watch out for CPU baselines too: Immich's machine learning container requires an x86-64-v2 CPU, so an older Xeon is a problem in the other direction.
Will my Postgres dump restore into a newer Postgres?
Yes, that direction works: pg_dump output is intended to load into servers newer than the version that produced it. The reverse is not guaranteed, and pg_dump refuses to dump from a server newer than itself. What definitely does not work is copying the Postgres data directory between major versions, because the on-disk format changes and you need pg_upgrade. If the new host's compose file pulls a newer image tag, take the dump before you touch it.
Which encryption keys do I have to bring with me?
The ones that are not in the database. n8n keys credential ciphertext to a key stored in the .n8n user folder, and a fresh instance silently generates a new one, so every credential fails to decrypt. Node-RED does the same via credentialSecret, or an auto-generated key hidden in .config.runtime.json. Actual Budget's end-to-end password cannot be reset or recovered. Vaultwarden needs ADMIN_TOKEN and the rsa_key* files. BookStack needs APP_KEY from .env.
How long should I keep the old machine?
Two weeks, powered off but not wiped. That is long enough to cover the weekly and fortnightly jobs you forgot existed: a scheduled report, a cron that only fires on Sundays, an integration that renews monthly. Powered off matters because a machine still running the same service will keep answering, keep writing to a shared database, and keep renewing certificates for a hostname that has moved.
Do I need to move certificates as well?
You do not need to, and mostly you should not. ACME certificates are cheap to reissue and moving them is usually more work than reissuing. What you must move is the ACME account state if you want to avoid rate limits during testing: Caddy's /data volume, or Traefik's acme.json, which must be mode 600 or Traefik ignores it. Let's Encrypt allows 50 certificates per registered domain per week, which a repeated test loop can exhaust.
Should I just restore the backup onto the new machine?
It is the best possible use of a migration: treat it as the restore rehearsal you have been putting off. Restore from the off-site repository rather than copying machine to machine, and time it. If the restore works you have proved the backup, and if it does not you have found that out on a day when the old machine is still sitting there working.
Sources#
- n8n documentation, set a custom encryption key
- Node-RED design note, encryption of credentials
- Node-RED Docker install, /data volume and uid 1000
- Actual Budget documentation, syncing and end-to-end encryption
- Vaultwarden wiki, backing up your vault
- PostgreSQL documentation, pg_dump and version compatibility
- Nextcloud admin manual, upgrade procedure
- Paperless-ngx administration docs, exporter and version matching
- BorgBackup installation, official binaries and platform support
- Immich documentation, hardware requirements
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.