How to Migrate a Self-Hosted App to a New VPS With Low Downtime

Move a working app to a new server with a checklist-driven cutover that reduces surprises, preserves rollback options, and keeps downtime short.

VPS migrationCutoverSelf-hosting
What you learn

How to prepare the new VPS, sync app files and data, test before DNS changes, and cut over with a rollback plan.

Best for

Docker Compose apps, Nginx-backed sites, small databases, and single-VPS services you want to move without a full platform migration.

Risk to watch

Changing DNS before you have tested the new stack thoroughly is the fastest way to turn a routine move into an outage.

Before you begin

  • SSH access to both the old VPS and the new VPS.
  • A clear list of app components: reverse proxy, containers or services, database, uploads, secrets, cron jobs, and TLS setup.
  • A recent backup of the old server or at least the app data and database.
  • DNS access for the domain you will cut over.
  • A maintenance window or low-traffic period for the final sync.

A VPS migration goes best when you think of it as two separate jobs. First, build a working copy of the stack on the new server. Second, do a small final sync and switch traffic. Most avoidable downtime happens when people combine those into one rushed session.

Expected outcome: By the end, the new VPS will serve the app, DNS will point to it, and you will still have a clean way to roll back to the old VPS if something looks wrong.

Step 1: Plan the cutover before touching production

Start by documenting what must move:

  • App code or Compose files
  • Environment files and secrets
  • Uploaded files and persistent volumes
  • Database contents
  • Nginx or Caddy config
  • Scheduled jobs, webhooks, and firewall rules

Then lower your DNS TTL a day before the migration if your provider allows it. A shorter TTL helps clients pick up the new IP faster after the cutover.

# Example checks on the old VPS
hostname
ip addr
sudo ss -tulpn
sudo systemctl --type=service --state=running
cd /opt/myapp && docker compose ps

If the app has a database, decide how you will get a consistent final copy. For small apps, this is often a short write freeze plus a final pg_dump, mysqldump, or one last rsync of upload directories.

Step 2: Prepare the new VPS so it matches your app

Set up the basics first: updates, firewall, required packages, Docker or runtime dependencies, and a non-root user if that is how you operate.

sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx curl git ufw
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

If your app uses Docker Compose, install Docker and the Compose plugin. If it runs under systemd, create the target directories and service user before moving files over.

Create the same folder structure you expect to use in production:

sudo mkdir -p /opt/myapp
sudo chown -R $USER:$USER /opt/myapp

Do not point DNS yet. The goal here is a dark launch, not a live cutover.

Step 3: Copy app files and persistent data safely

Copy the application files first, excluding caches and junk:

rsync -avz \
  --exclude '.git/' \
  --exclude 'node_modules/' \
  --exclude '.env' \
  /opt/myapp/ ubuntu@new-vps:/opt/myapp/

Copy the environment file separately and carefully:

scp /opt/myapp/.env ubuntu@new-vps:/opt/myapp/.env

For uploaded files or bind-mounted storage, do an initial rsync while the old app is still live:

rsync -avz /srv/myapp-uploads/ ubuntu@new-vps:/srv/myapp-uploads/

For Postgres, take a logical dump on the old VPS and restore it on the new one:

pg_dump -U myapp_user -d myapp_db -Fc -f /tmp/myapp_db.dump
scp /tmp/myapp_db.dump ubuntu@new-vps:/tmp/
pg_restore -U myapp_user -d myapp_db --clean --if-exists /tmp/myapp_db.dump

If the database runs inside Docker, run the dump from the container:

docker exec -t postgres pg_dump -U myapp_user -d myapp_db -Fc > /tmp/myapp_db.dump

Bring the app up on the new server, but keep public traffic away from it for now:

cd /opt/myapp
docker compose up -d

Step 4: Test the new VPS before changing DNS

Use the new server IP or a temporary hosts-file override to test the app privately. Check that pages load, logins work, uploads are present, and background jobs start normally.

curl -I http://NEW_VPS_IP
curl -H 'Host: example.com' http://NEW_VPS_IP
cd /opt/myapp && docker compose logs --tail=100
sudo journalctl -u nginx -n 50 --no-pager

If HTTPS depends on the final domain, you can wait until cutover for certificate issuance, but the app itself should already behave correctly behind plain HTTP or a temporary internal test path.

Warning: Do not assume a 200 response means the migration is done. Test writes too, especially admin actions, form submissions, uploads, and background jobs.

Step 5: Do the final sync and cut over traffic

When you are ready, pause writes on the old VPS if the app can tolerate a brief maintenance mode. Then run one final data sync so the new server starts from the latest state.

# Example: stop the app briefly on the old VPS
cd /opt/myapp && docker compose stop app

# Final upload sync
rsync -avz --delete /srv/myapp-uploads/ ubuntu@new-vps:/srv/myapp-uploads/

# Final Postgres dump and transfer
pg_dump -U myapp_user -d myapp_db -Fc -f /tmp/myapp_db-final.dump
scp /tmp/myapp_db-final.dump ubuntu@new-vps:/tmp/
pg_restore -U myapp_user -d myapp_db --clean --if-exists /tmp/myapp_db-final.dump

Start or restart the app on the new VPS, verify again, then update DNS to point to the new server. If you use Nginx, confirm the server block is loaded and reload cleanly:

sudo nginx -t && sudo systemctl reload nginx

Keep the old VPS running for a while instead of tearing it down immediately. That is part of your rollback safety.

Rollback and recovery if the cutover goes wrong

A low-stress migration always has a simple rollback path. The easiest one is keeping the old VPS intact and ready to serve again.

  • If the new server fails quickly after cutover, point DNS back to the old VPS.
  • If only Nginx or app config is wrong, fix the new server before changing DNS again.
  • If new writes landed on the new VPS before rollback, capture them before switching back or you risk split-brain data.

For a simple rollback, bring the old app fully back online and confirm it is healthy:

cd /opt/myapp && docker compose up -d
curl -I http://OLD_VPS_IP

The hardest rollback scenario is partial traffic and partial writes on both servers. Avoid that by keeping the write freeze short but real during the final sync.

Step 6: Verify the outcome after cutover

When the migration is successful, you should see:

  • The domain resolves to the new VPS.
  • The app responds correctly from the new server.
  • Uploads, database records, and background jobs match the old environment.
  • Logs are clean enough that you can explain any remaining warnings.
dig +short example.com
curl -I https://example.com
ssh ubuntu@new-vps 'cd /opt/myapp && docker compose ps'
ssh ubuntu@new-vps 'sudo journalctl -u nginx -n 100 --no-pager'

Troubleshooting common migration problems

DNS points to the new VPS, but the old site still appears.
You may be seeing cached DNS or cached HTML from a CDN. Confirm the IP directly with dig and test with curl.

The app loads, but uploads are missing.
The persistent storage path probably did not migrate, or the mount path changed on the new server.

The database restore worked, but the app cannot log in.
Check connection strings, passwords, and whether the app is still pointing at the old DB host.

HTTPS fails after cutover.
Confirm ports 80 and 443 are open, the server block uses the correct domain, and certificate issuance can reach the new VPS.

Background jobs stopped.
Migrations often miss cron entries, systemd timers, or worker containers. Compare the old server and the new one explicitly.

What to do next

Once the new VPS is stable, the next useful improvement is building safer recurring backups so future server moves are less stressful. Continue with Back Up a VPS and Restore It Safely.