Migrate Self-Hosted Services Between Servers

What Is Server Migration?

Server migration means moving your self-hosted services — Docker containers, volumes, databases, configs, and reverse proxy settings — from one server to another. Common reasons: upgrading hardware, switching from a VPS to a home server (or vice versa), or moving to a more powerful machine.

Done right, migration results in zero data loss and minimal downtime. Done wrong, you lose your photo library, break your password manager, or corrupt your databases.

Prerequisites

  • SSH access to both old and new servers (SSH Setup)
  • Docker and Docker Compose installed on the new server (Docker Compose Basics)
  • Enough storage on the new server for all your data
  • A basic understanding of Docker volumes (Docker Volumes)

Migration Strategy Overview

StepWhatRisk Level
1Inventory everything on the old serverNone
2Set up Docker on the new serverNone
3Copy Docker Compose filesNone
4Stop services on old serverDowntime starts
5Transfer volumes and dataData loss if interrupted
6Start services on new serverNone
7Verify everything worksNone
8Update DNS/reverse proxyDowntime ends

Total expected downtime: 10 minutes to several hours, depending on data volume and network speed between servers.

Step 1: Inventory the Old Server

List everything running:

# List all running containers
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"

# List all Docker Compose projects
docker compose ls

# List all volumes
docker volume ls

# Check total data size
du -sh /srv/*
du -sh /var/lib/docker/volumes/*

Document every service, its Compose file location, volume mount paths, and environment files. Create a migration checklist:

Service: nextcloud
Compose file: /srv/nextcloud/docker-compose.yml
Env file: /srv/nextcloud/.env
Volumes:
  - /srv/nextcloud/html → /var/www/html
  - /srv/nextcloud/data → /var/www/html/data
  - nextcloud-db → /var/lib/mysql
Data size: 45 GB
Database: MariaDB (included in compose)

Step 2: Prepare the New Server

# Install Docker
curl -fsSL https://get.docker.com | sh

# Add your user to docker group
sudo usermod -aG docker $USER

# Create the same directory structure
sudo mkdir -p /srv/nextcloud /srv/jellyfin /srv/vaultwarden
# (mirror your old server's structure)

Match the directory structure exactly. If your old server stores data in /srv/, use /srv/ on the new one. This avoids editing every Compose file.

Step 3: Copy Docker Compose Files

Transfer all Compose files and environment files:

# From the old server, use rsync
rsync -avz --progress /srv/*/docker-compose.yml /srv/*/.env \
  user@new-server:/srv/

# Or use scp for individual services
scp -r /srv/nextcloud/docker-compose.yml /srv/nextcloud/.env \
  user@new-server:/srv/nextcloud/

Verify the files arrived intact:

# On the new server
diff <(ssh user@old-server cat /srv/nextcloud/docker-compose.yml) \
     /srv/nextcloud/docker-compose.yml

Step 4: Stop Services on Old Server

Stop containers to ensure data consistency. Running containers can write data during transfer, causing corruption — especially databases.

# Stop all containers gracefully
docker stop $(docker ps -q)

# Or stop specific compose projects
cd /srv/nextcloud && docker compose down
cd /srv/jellyfin && docker compose down

Downtime starts now. If you have a status page (like Uptime Kuma), update it before stopping services.

Step 5: Transfer Data

rsync is the best tool for server-to-server transfers. It handles large files, can resume interrupted transfers, and preserves permissions.

# Transfer bind-mounted data
rsync -avz --progress -e ssh /srv/ user@new-server:/srv/

# Transfer named Docker volumes
# First, find where they're stored
docker volume inspect nextcloud-db --format '{{.Mountpoint}}'
# /var/lib/docker/volumes/nextcloud-db/_data

rsync -avz --progress -e ssh \
  /var/lib/docker/volumes/ user@new-server:/var/lib/docker/volumes/

For large datasets (100+ GB), consider running rsync with --compress for WAN transfers or --no-compress for LAN transfers.

Option B: tar + SSH (For Smaller Datasets)

# Pipe tar directly through SSH
cd /srv && tar czf - nextcloud/ | ssh user@new-server "cd /srv && tar xzf -"

Option C: Physical Disk Move

If both servers are local and use the same filesystem, you can physically move the drive:

  1. Stop containers on old server
  2. Unmount the data drive
  3. Install it in the new server
  4. Mount at the same path

Transferring Named Docker Volumes

Named volumes live in /var/lib/docker/volumes/. You have two options:

Direct copy (if you have root SSH access):

sudo rsync -avz --progress -e ssh \
  /var/lib/docker/volumes/myapp_data/ \
  root@new-server:/var/lib/docker/volumes/myapp_data/

Export/import with tar:

# On old server: export volume to tar
docker run --rm -v myapp_data:/data -v /tmp:/backup \
  alpine tar czf /backup/myapp_data.tar.gz -C /data .

# Transfer the tar
scp /tmp/myapp_data.tar.gz user@new-server:/tmp/

# On new server: create volume and import
docker volume create myapp_data
docker run --rm -v myapp_data:/data -v /tmp:/backup \
  alpine tar xzf /backup/myapp_data.tar.gz -C /data

Step 6: Start Services on New Server

# Start services one at a time and verify each
cd /srv/nextcloud && docker compose up -d
docker compose logs -f  # Watch for errors

cd /srv/jellyfin && docker compose up -d
docker compose logs -f

Check each service’s web UI before starting the next one. Fix any issues before proceeding.

Common Post-Migration Issues

SymptomCauseFix
Permission deniedUID/GID mismatchchown -R 1000:1000 /srv/myapp
Database connection refusedContainer name changedCheck service names in Compose file
Bind mount emptyPath doesn’t exist on new serverCreate the directory, re-transfer data
Container can’t pull imageDocker not logged into registrydocker login ghcr.io

Step 7: Verify Data Integrity

For each service:

# Check container health
docker ps  # All containers should show "Up" and "healthy"

# Check logs for errors
docker compose logs --tail=50

# Verify data exists
docker exec nextcloud ls /var/www/html/data/

# For databases, verify tables exist
docker exec nextcloud-db mysql -u root -p -e "SHOW DATABASES;"

Test the application through its web UI. Log in, verify your data is present, upload a test file, check that existing files are accessible.

Step 8: Update DNS and Reverse Proxy

Once everything is verified on the new server:

# Update DNS records to point to new server's IP
# If using Cloudflare, update the A record
# Old: domain.com → old-server-ip
# New: domain.com → new-server-ip

If you’re using Cloudflare Tunnel, install cloudflared on the new server and re-configure the tunnel.

If you’re using a reverse proxy, set it up on the new server with the same configuration.

DNS propagation can take minutes to hours. Keep the old server running until you’re confident all traffic is hitting the new server.

Database-Specific Migration

PostgreSQL

# On old server: dump all databases
docker exec postgres pg_dumpall -U postgres > /tmp/pg_backup.sql

# Transfer
scp /tmp/pg_backup.sql user@new-server:/tmp/

# On new server: start PostgreSQL first, then restore
cd /srv/postgres && docker compose up -d
docker exec -i postgres psql -U postgres < /tmp/pg_backup.sql

MariaDB/MySQL

# On old server: dump all databases
docker exec mariadb mysqldump -u root --password=yourpass \
  --all-databases > /tmp/mysql_backup.sql

# Transfer and restore on new server
scp /tmp/mysql_backup.sql user@new-server:/tmp/
docker exec -i mariadb mysql -u root --password=yourpass < /tmp/mysql_backup.sql

SQLite

SQLite databases are single files. Just copy them. Make sure the container is stopped first — copying a SQLite database while it’s being written to can corrupt it.

Common Mistakes

Transferring Data While Containers Are Running

Databases are the biggest risk. A database being written to during a copy can result in a corrupted, unrecoverable backup. Always stop containers first.

Forgetting .env Files

Docker Compose .env files contain passwords, API keys, and configuration. They’re easy to miss because they’re hidden files. Always include them in your transfer.

Not Verifying Before Decommissioning

Keep the old server running for at least a week after migration. Users will find issues you didn’t. Having the old server available as a fallback is worth the extra cost.

Mismatched Docker Versions

If the new server runs a much newer or older Docker version, volume formats or Compose syntax might differ. Match Docker versions or test thoroughly before decommissioning the old server.

Next Steps

FAQ

How long does server migration take?

It depends on your data volume and network speed. 10 GB over a fast LAN takes minutes. 500 GB over the internet can take hours. Plan for the data transfer step to take the longest.

Can I migrate without downtime?

Near-zero downtime is possible with a two-phase approach: rsync data while the old server is still running (first pass), stop services, rsync again (only changes transfer, which is fast), then switch DNS. Total downtime is just the final rsync + DNS propagation.

Should I use Docker volumes or bind mounts on the new server?

Bind mounts (host paths like /srv/myapp) are easier to back up, inspect, and migrate. Named Docker volumes are cleaner in Compose files but harder to manage externally. For self-hosting, bind mounts are generally recommended.

Can I migrate from x86 to ARM (or vice versa)?

Data transfers fine. But your Docker images need to support the target architecture. Check Docker Hub for multi-arch image support. Many popular self-hosted apps now publish multi-arch images. See Docker Multi-Arch.

What about Tailscale/VPN connections?

Re-install Tailscale or WireGuard on the new server. Tailscale nodes get new IPs by default, so update any hardcoded references. You can request the same Tailscale IP through the admin console.

Comments