Migrate Docker Apps Between VPS Providers — Zero-Downtime Container Migration
Docker was supposed to make migrations trivial. "It runs in a container, just move the container." I used to believe that, and then I actually tried to migrate a production docker-compose stack with three services, two persistent volumes, and a PostgreSQL database from a Linode instance to Hetzner. It was not as simple as copying a folder.
The application containers are the easy part — they are stateless, defined in your docker-compose.yml, and can be rebuilt from images in seconds. The hard part is the data. Database volumes, uploaded files, SSL certificates, environment-specific configs, cron jobs running inside containers — all the state that makes your application actually work. Move the containers without the state and you get a beautifully running app that serves empty pages from an empty database.
I have migrated docker-compose stacks between VPS providers nine times now. What follows is the process I have refined to be reliable, repeatable, and achievable with less than 5 minutes of downtime for most applications.
docker compose up -d away.
Table of Contents
- Why Docker Makes Migration Easier (and Where It Doesn’t)
- Pre-Migration Checklist
- Step 1: Inventory Your Docker Environment
- Step 2: Set Up Docker on the New VPS
- Step 3: Transfer Images (Registry or Save/Load)
- Step 4: Transfer Configuration Files
- Step 5: Migrate Docker Volumes
- Step 6: Migrate Database Containers (The Hard Part)
- Step 7: Start the Stack on the New Server
- Step 8: Test Everything
- Step 9: DNS Cutover
- Rollback Plan
- Advanced: Docker Swarm and Multi-Node Migration
- VPS Cost Comparison for Docker Workloads
- FAQ
1. Why Docker Makes Migration Easier (and Where It Doesn’t)
Docker genuinely simplifies migration in two important ways. First, your entire application stack is defined in a docker-compose.yml file — there is no need to remember which packages you installed, which config files you tweaked, or which systemd services you created. The compose file is the single source of truth. Second, your application runs in the exact same environment regardless of the host OS. Ubuntu, Debian, Rocky Linux — Docker does not care. You can migrate from an Ubuntu 20.04 VPS to a Debian 12 VPS and your containers will not notice the difference.
Where Docker does NOT help:
- Persistent data in volumes: Docker volumes are just directories on the host filesystem. They do not magically transfer themselves. You have to move them.
- Database state: A running PostgreSQL or MySQL container has data in memory (WAL buffers, transaction logs) that is not on disk yet. You cannot just copy the volume while the container is running and expect a consistent backup.
- Network configuration: Docker networks, port mappings, and firewall rules are host-specific. They do not transfer with the container.
- Secrets and environment variables: Your .env file is not part of the Docker image. Forget to copy it and your app crashes at startup.
2. Pre-Migration Checklist
- Ensure docker-compose.yml and all Dockerfiles are in a Git repository
- List all Docker volumes:
docker volume ls - List all running containers and their resource usage:
docker stats --no-stream - Verify total disk usage of volumes:
docker system df -v - Ensure all custom images are pushed to a registry (Docker Hub, GHCR, etc.)
- Copy .env files and any bind-mounted config files to a safe location
- Provision the new VPS with enough disk space for volumes + room to grow
- Lower DNS TTL to 300 seconds 24 hours before cutover
- Take a snapshot of the old VPS as a fallback
3. Step 1: Inventory Your Docker Environment
Before migrating anything, document what you have. I have made the mistake of starting a migration and discovering a bind-mounted directory I did not know about halfway through. Run these commands on the old server and save the output:
# === Run all of these on the OLD server ===
# List all containers (running and stopped):
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
# List all volumes and their sizes:
docker system df -v 2>/dev/null | grep -A 100 "VOLUME NAME"
# Or:
for vol in $(docker volume ls -q); do
size=$(docker run --rm -v "$vol":/data alpine du -sh /data 2>/dev/null | cut -f1)
echo "$vol: $size"
done
# List all images:
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# List all networks:
docker network ls --format "table {{.Name}}\t{{.Driver}}\t{{.Scope}}"
# Show docker-compose configuration (from your project directory):
cd /opt/myapp # or wherever your docker-compose.yml lives
docker compose config # Shows the resolved, complete configuration
# List bind mounts (files/dirs mounted from host into containers):
docker inspect --format='{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}{{end}}' $(docker ps -q)
# List environment variables per container:
for container in $(docker ps --format '{{.Names}}'); do
echo "=== $container ==="
docker inspect --format='{{range .Config.Env}}{{.}}{{"\n"}}{{end}}' "$container"
echo ""
done
# Save all of this to a file:
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" > /tmp/docker-inventory.txt
docker volume ls >> /tmp/docker-inventory.txt
docker images >> /tmp/docker-inventory.txt
4. Step 2: Set Up Docker on the New VPS
# === On the NEW VPS ===
# Install Docker (official method for Ubuntu/Debian):
curl -fsSL https://get.docker.com | sh
# Add your user to the docker group:
usermod -aG docker deploy
# Install Docker Compose V2 (included with Docker Engine now, but verify):
docker compose version
# Should show: Docker Compose version v2.x.x
# Configure Docker daemon (optional but recommended):
cat > /etc/docker/daemon.json << 'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
EOF
systemctl restart docker
# Configure firewall:
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
# Create the project directory (match the old server's structure):
mkdir -p /opt/myapp
5. Step 3: Transfer Images (Registry or Save/Load)
# === Method A: Pull from registry (preferred — fastest and cleanest) ===
# If your images are on Docker Hub or a private registry:
# Just run docker compose pull on the new server. Done.
cd /opt/myapp
docker compose pull
# === Method B: Push custom images to a registry first ===
# On the OLD server: tag and push your custom images
docker tag myapp-web:latest ghcr.io/yourorg/myapp-web:latest
docker push ghcr.io/yourorg/myapp-web:latest
docker tag myapp-worker:latest ghcr.io/yourorg/myapp-worker:latest
docker push ghcr.io/yourorg/myapp-worker:latest
# On the NEW server: pull them
docker pull ghcr.io/yourorg/myapp-web:latest
docker pull ghcr.io/yourorg/myapp-worker:latest
# === Method C: docker save/load (when no registry is available) ===
# On the OLD server: save images to tar files
docker save myapp-web:latest | gzip > /tmp/myapp-web.tar.gz
docker save myapp-worker:latest | gzip > /tmp/myapp-worker.tar.gz
# Transfer to new server:
scp /tmp/myapp-*.tar.gz deploy@NEW_VPS_IP:/tmp/
# On the NEW server: load images
docker load < /tmp/myapp-web.tar.gz
docker load < /tmp/myapp-worker.tar.gz
# Verify images are present:
docker images
6. Step 4: Transfer Configuration Files
# Transfer your entire project directory (docker-compose.yml, Dockerfiles, .env, configs):
rsync -avzP \
--exclude='node_modules' \
--exclude='__pycache__' \
--exclude='.git' \
/opt/myapp/ \
deploy@NEW_VPS_IP:/opt/myapp/
# CRITICAL: Review and update the .env file on the new server!
ssh deploy@NEW_VPS_IP "cat /opt/myapp/.env"
# Things that WILL need updating in .env:
# - SERVER_IP or HOSTNAME (if referenced)
# - DATABASE_URL (if pointing to old server's IP)
# - REDIS_URL (if external)
# - WEBHOOK_URLS (if they include the old IP)
# - SSL_CERT_PATH (if bind-mounted)
# - DOMAIN_NAME (probably stays the same)
# Transfer bind-mounted directories:
# Check what directories are bind-mounted from the old server:
docker inspect --format='{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}{{"\n"}}{{end}}{{end}}' $(docker ps -q) | sort -u
# rsync each bind-mounted directory:
rsync -avzP /opt/myapp/data/ deploy@NEW_VPS_IP:/opt/myapp/data/
rsync -avzP /opt/myapp/uploads/ deploy@NEW_VPS_IP:/opt/myapp/uploads/
rsync -avzP /opt/myapp/nginx/ deploy@NEW_VPS_IP:/opt/myapp/nginx/
7. Step 5: Migrate Docker Volumes
Docker named volumes live in /var/lib/docker/volumes/. For non-database volumes (file uploads, static assets, logs), you can transfer them directly. For database volumes, skip this section and use the native dump method in Step 6.
# === Method A: Transfer volumes using Docker and tar ===
# (Safer than copying raw files — handles permissions correctly)
# On the OLD server, for each non-database volume:
# List your volumes:
docker volume ls --format '{{.Name}}'
# Export a volume to a tar archive:
docker run --rm \
-v myapp_uploads:/data \
-v /tmp:/backup \
alpine tar czf /backup/myapp_uploads.tar.gz -C /data .
docker run --rm \
-v myapp_static:/data \
-v /tmp:/backup \
alpine tar czf /backup/myapp_static.tar.gz -C /data .
# Transfer to new server:
scp /tmp/myapp_uploads.tar.gz deploy@NEW_VPS_IP:/tmp/
scp /tmp/myapp_static.tar.gz deploy@NEW_VPS_IP:/tmp/
# On the NEW server, import volumes:
# First create the volumes:
docker volume create myapp_uploads
docker volume create myapp_static
# Restore from tar:
docker run --rm \
-v myapp_uploads:/data \
-v /tmp:/backup \
alpine tar xzf /backup/myapp_uploads.tar.gz -C /data
docker run --rm \
-v myapp_static:/data \
-v /tmp:/backup \
alpine tar xzf /backup/myapp_static.tar.gz -C /data
# Verify:
docker run --rm -v myapp_uploads:/data alpine ls -la /data/
# === Method B: rsync volumes directly (faster for large volumes) ===
# WARNING: Only do this for NON-database volumes. Stop containers first.
# On the OLD server:
docker compose stop # Stop all containers to ensure consistency
# rsync the volume data:
rsync -avzP /var/lib/docker/volumes/myapp_uploads/ \
root@NEW_VPS_IP:/var/lib/docker/volumes/myapp_uploads/
rsync -avzP /var/lib/docker/volumes/myapp_static/ \
root@NEW_VPS_IP:/var/lib/docker/volumes/myapp_static/
# Restart containers on old server:
docker compose start
8. Step 6: Migrate Database Containers (The Hard Part)
This is where most Docker migrations go wrong. People copy the database volume while the database is running, and the restore fails with corruption errors. Always use the database’s native dump tools. Always.
# === PostgreSQL Container Migration ===
# Step 1: Dump from the running container on the OLD server:
docker exec myapp-postgres pg_dumpall -U postgres | gzip > /tmp/postgres-dump.sql.gz
# Verify the dump is not empty:
ls -lh /tmp/postgres-dump.sql.gz
# Should be more than a few KB
# Step 2: Transfer to new server:
scp /tmp/postgres-dump.sql.gz deploy@NEW_VPS_IP:/tmp/
# Step 3: On the NEW server, start ONLY the database container:
cd /opt/myapp
docker compose up -d postgres # Start just the DB container
# Wait for PostgreSQL to be ready:
until docker exec myapp-postgres pg_isready -U postgres; do
sleep 2
echo "Waiting for PostgreSQL..."
done
# Step 4: Restore the dump:
gunzip -c /tmp/postgres-dump.sql.gz | docker exec -i myapp-postgres psql -U postgres
# Step 5: Verify:
docker exec myapp-postgres psql -U postgres -d myappdb \
-c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"
# === MySQL Container Migration ===
# Dump from old server:
docker exec myapp-mysql mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" \
--all-databases --single-transaction --routines --triggers \
| gzip > /tmp/mysql-dump.sql.gz
# Transfer:
scp /tmp/mysql-dump.sql.gz deploy@NEW_VPS_IP:/tmp/
# On new server, start MySQL container and restore:
docker compose up -d mysql
sleep 15 # Wait for MySQL to initialize
gunzip -c /tmp/mysql-dump.sql.gz | docker exec -i myapp-mysql mysql -u root -p"$MYSQL_ROOT_PASSWORD"
# Verify:
docker exec myapp-mysql mysql -u root -p"$MYSQL_ROOT_PASSWORD" \
-e "SELECT table_schema, COUNT(*) as tables FROM information_schema.tables \
WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys') \
GROUP BY table_schema;"
# === Redis Container Migration ===
# For cache-only Redis: skip migration. Start fresh on the new server.
# For persistent Redis data:
# Option 1: Use BGSAVE and transfer the dump file
docker exec myapp-redis redis-cli BGSAVE
docker exec myapp-redis redis-cli LASTSAVE # Note the timestamp
sleep 5 # Wait for save to complete
# Copy the dump file out of the container:
docker cp myapp-redis:/data/dump.rdb /tmp/redis-dump.rdb
scp /tmp/redis-dump.rdb deploy@NEW_VPS_IP:/tmp/
# On new server, place the dump before starting Redis:
docker volume create myapp_redis_data
docker run --rm -v myapp_redis_data:/data -v /tmp:/backup alpine \
cp /backup/redis-dump.rdb /data/dump.rdb
# Start Redis — it will load the dump automatically:
docker compose up -d redis
9. Step 7: Start the Stack on the New Server
# On the NEW server:
cd /opt/myapp
# Start all services:
docker compose up -d
# Check that all containers are running:
docker compose ps
# Check logs for any startup errors:
docker compose logs --tail=50
# If a container is restarting or exited, check its logs specifically:
docker compose logs --tail=100 web
docker compose logs --tail=100 worker
# Common issues at this point:
# - .env file missing or incorrect → fix environment variables
# - Database not ready when app starts → add depends_on + healthcheck
# - Permission denied on volumes → check volume ownership
# - Port already in use → check for conflicts with host services
10. Step 8: Test Everything
# Test by IP (bypass DNS):
curl -sH "Host: yourdomain.com" http://NEW_VPS_IP/
curl -sH "Host: yourdomain.com" http://NEW_VPS_IP/api/health
# Test database data integrity:
docker exec myapp-postgres psql -U postgres -d myappdb \
-c "SELECT COUNT(*) FROM users;"
# Compare with the count on the old server
# Test file uploads (check a known file):
curl -sI -H "Host: yourdomain.com" http://NEW_VPS_IP/uploads/known-file.jpg
# Test Redis connectivity:
docker exec myapp-redis redis-cli PING
# Run application tests if available:
docker compose exec web npm test
# Load test:
hey -n 5000 -c 25 -host "yourdomain.com" http://NEW_VPS_IP/
# Check resource usage:
docker stats --no-stream
11. Step 9: DNS Cutover
# === Pre-cutover: Final data sync ===
# 1. Enable maintenance mode on old server (if your app supports it):
ssh root@OLD_VPS_IP "docker exec myapp-web touch /app/maintenance.flag"
# 2. Final database dump (captures writes since last sync):
ssh root@OLD_VPS_IP "docker exec myapp-postgres pg_dumpall -U postgres | gzip > /tmp/final-dump.sql.gz"
scp root@OLD_VPS_IP:/tmp/final-dump.sql.gz /tmp/
# 3. Restore on new server:
scp /tmp/final-dump.sql.gz deploy@NEW_VPS_IP:/tmp/
ssh deploy@NEW_VPS_IP "gunzip -c /tmp/final-dump.sql.gz | docker exec -i myapp-postgres psql -U postgres"
# 4. Final volume sync (uploads that happened since first sync):
ssh root@OLD_VPS_IP "docker run --rm -v myapp_uploads:/data -v /tmp:/backup alpine tar czf /backup/final-uploads.tar.gz -C /data ."
scp root@OLD_VPS_IP:/tmp/final-uploads.tar.gz deploy@NEW_VPS_IP:/tmp/
ssh deploy@NEW_VPS_IP "docker run --rm -v myapp_uploads:/data -v /tmp:/backup alpine tar xzf /backup/final-uploads.tar.gz -C /data"
# === DNS Update ===
# Update A record to point to NEW_VPS_IP
# (Via your DNS provider's dashboard or API)
# === Post-cutover: Get SSL certificate ===
# If using Traefik or nginx-proxy with Let's Encrypt:
# The cert will be issued automatically when traffic hits the new server.
# If using certbot manually:
ssh root@NEW_VPS_IP "certbot --nginx -d yourdomain.com -d www.yourdomain.com"
# === Verify ===
dig yourdomain.com +short
curl -I https://yourdomain.com
12. Rollback Plan
- Point DNS back to old VPS IP (5-minute propagation with 300s TTL)
- Remove maintenance flag:
docker exec myapp-web rm /app/maintenance.flag - Old server containers are still running with original data
- If writes happened on new server, export and merge back to old
Keep the old VPS running for 7–14 days. Do not destroy it until you are confident the new server is stable. Take a VPS snapshot of the old server before deletion.
13. Advanced: Docker Swarm and Multi-Node Migration
If you are already running Docker Swarm, migration can be truly zero-downtime by adding the new VPS as a node before removing the old one:
# === Docker Swarm: Rolling Migration ===
# On the NEW VPS, join the existing swarm:
docker swarm join --token SWMTKN-1-xxxxx MANAGER_IP:2377
# On the manager, verify the new node:
docker node ls
# Drain the OLD node (moves all services to other nodes):
docker node update --availability drain OLD_NODE_ID
# Wait for all services to migrate:
docker service ls
docker service ps your-service-name
# When satisfied, remove the old node:
docker node rm OLD_NODE_ID
# The key limitation: this does NOT migrate persistent volumes.
# Database and storage volumes still need the manual dump/restore approach.
# Swarm migration works best for stateless services.
14. VPS Cost Comparison for Docker Workloads
Docker containers add overhead. You need more RAM than a bare-metal installation of the same software. A good rule of thumb: plan for 25–30% more RAM than your containers actually use. Here are the best-value VPS plans for Docker workloads:
| Provider | Best Docker Plan | Price | Why |
|---|---|---|---|
| Contabo | 4 vCPU / 8GB / 200GB SSD | $6.99/mo | Best RAM/$ ratio. 32TB BW. |
| Hetzner | CX32: 4 vCPU / 8GB / 80GB | $8.49/mo | Best performance/$. 20TB BW. Hourly billing. |
| Hostinger VPS | KVM 2: 2 vCPU / 8GB / 100GB NVMe | $8.99/mo | NVMe storage. Great for I/O-heavy Docker workloads. |
| Vultr | 2 vCPU / 4GB / 80GB SSD | $20/mo | 9 US locations. DDoS protection. Hourly billing. |
| Kamatera | 4 vCPU / 4GB / 60GB SSD | $18/mo | Custom configs. $100 free trial. 5TB BW. |
For Docker workloads specifically, I recommend at minimum 4GB RAM. A typical docker-compose stack (web app + PostgreSQL + Redis + Nginx reverse proxy) uses 1.5–2.5GB of RAM. With Docker overhead, system processes, and headroom, 4GB is the comfortable minimum. If you run more than 5 containers, go with 8GB. See our Docker on VPS guide and VPS size calculator for more detailed sizing recommendations.
For related reading: migrating from AWS to independent VPS, VPS backup strategies (critical for Docker volumes), and our complete Docker VPS guide.
FAQ
Can I just copy Docker volumes directly between servers?
For non-database volumes (file uploads, static assets, logs): yes, but stop the containers first to ensure data consistency. Docker volumes are directories in /var/lib/docker/volumes/. You can tar them, transfer via rsync, and extract on the new server. For database volumes: never copy raw files while the database is running. Always use the database’s native dump tool (pg_dump, mysqldump) to get a consistent export.
Should I transfer Docker images or rebuild them on the new server?
If your images are on Docker Hub or a private registry (GitHub Container Registry, GitLab Registry), just pull them on the new server — docker compose pull handles this. If you build custom images locally, push them to a registry first. Transferring images via docker save | gzip / docker load works but is slower for large images. Use it only when no registry is available.
How do I migrate Docker containers with persistent databases?
Always use the database’s native export tools. PostgreSQL: docker exec container pg_dumpall -U postgres > dump.sql. MySQL: docker exec container mysqldump --all-databases --single-transaction > dump.sql. Transfer the dump file, start the database container on the new server, import. This guarantees a consistent, corruption-free migration. Never copy the volume directory while the database container is running.
What about Docker secrets and environment variables?
Docker Compose .env files and environment variables are NOT stored in volumes or images — you must transfer them separately. Copy your .env file, docker-compose.yml, and any secret files referenced by the compose configuration to the new server. Review each value: database connection strings, API endpoints, webhook URLs, and any IP-specific settings will likely need updating to reflect the new server.
How long does a Docker migration between VPS providers take?
A simple docker-compose stack (web app + database + Redis): 1–2 hours of active work. A complex multi-service architecture with large databases: 4–8 hours. The bottleneck is data transfer — a 20GB database volume takes about 5 minutes to transfer between VPS providers at typical network speeds. Total project time including testing and DNS propagation is usually 1–3 days.
Can I use Docker Swarm or Kubernetes for zero-downtime migration?
If you are already running Docker Swarm, add the new VPS as a worker node, drain the old node, and let Swarm redistribute services. For Kubernetes: add the new node, cordon the old one, and the scheduler handles the rest. Both achieve true zero-downtime for stateless services. The caveat: persistent volumes (databases) still need the manual dump/restore approach. Orchestration helps with application containers but does not solve the data migration problem.
Do I need the exact same Docker version on the new server?
Not the exact version, but stay within the same major version for safety. Docker maintains strong backward compatibility. If the old server runs Docker 24.x, installing Docker 25.x or 26.x on the new server works fine. Your docker-compose.yml files are compatible across Docker Compose V2 versions. The only potential issue is if you rely on deprecated features that were removed in a newer Docker release, which is rare.