How to Restore a Docker Compose App on a Fresh VPS After a Failure

Recover a broken self-hosted app onto a clean VPS with a repeatable restore order for Docker Compose, secrets, volumes, databases, reverse proxying, and final verification before you reopen traffic.

Disaster recoveryDocker ComposeFresh VPS restore
What you learn

How to rebuild a base server, restore app files and secrets, recover databases and uploads safely, bring the stack up in the right order, and verify the service before traffic returns.

Best for

Small self-hosted apps on one VPS using Docker Compose, Nginx, named volumes, bind mounts, and common databases like Postgres or MariaDB.

Risk to watch

The fastest way to turn a recovery into a second outage is restoring data in the wrong order or reopening traffic before you have proven the new host behaves correctly.

Before you begin

  • A replacement VPS running a supported Linux distribution such as Ubuntu.
  • Backups of the app's Compose files, environment files, volumes, databases, or exported dumps.
  • DNS or proxy access so you can point traffic at the recovered host when ready.
  • SSH access and enough confidence to test on the new host before reopening production traffic.

This guide is about failure recovery, not a planned migration. In a migration, the old server is usually alive long enough for a clean final sync. In a recovery, you may be working from backups only, under time pressure, with partial information. The goal is not zero downtime. The goal is to restore service safely and predictably.

Expected outcome: You will end up with a fresh VPS serving the app, a verified data restore, and a short recovery checklist you can reuse the next time you need it.

Step 1: Assess what failed and what recovery assets still exist

Before typing commands on the new host, inventory what you still have:

  • The Compose project files or a repo checkout
  • The latest .env file or encrypted secret source
  • Named-volume backups, bind-mounted uploads, or filesystem snapshots
  • Database dumps such as pg_dump or mysqldump exports
  • Reverse proxy config and TLS notes
  • Any worker, cron, or webhook configuration that lives outside the main app container

If the old VPS is still reachable, treat it as evidence first and a restore source second. Confirm whether the failure is storage loss, package corruption, container drift, or only one bad service. Do not start destructive cleanup on the old host until you understand whether it contains the newest surviving data.

# Example evidence collection on the old host if it is still reachable
hostname
uptime
df -h
free -h
cd /opt/myapp && docker compose ps
cd /opt/myapp && docker compose logs --tail=100

If the old host is fully gone, write down the recovery boundary plainly: which backups exist, when they were taken, and what data might be missing after restore. That honesty matters more than pretending the recovery point is perfect.

Step 2: Rebuild the base VPS first

Bring the replacement server to a known-good baseline before you touch app data. Install updates, enable SSH access, open only the ports you actually need, and install Docker plus the Compose plugin if your app depends on it.

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

Install Docker using the method you trust for your distribution, then confirm the Compose plugin is present:

docker --version
docker compose version

Create the app directory structure before copying anything else:

sudo mkdir -p /opt/myapp
sudo mkdir -p /srv/myapp-uploads
sudo chown -R $USER:$USER /opt/myapp /srv/myapp-uploads
Warning: Do not point DNS at the new VPS just because the base packages are installed. Recovery traffic should stay dark until the app and data are actually verified.

Step 3: Restore app files and secrets carefully

Recover the Compose project first. If the app is stored in Git, a clean checkout is usually safer than copying a random old working tree. If you keep the project in a backup archive instead, restore only the files you need.

cd /opt
git clone https://example.com/myapp.git
cd /opt/myapp

If you are restoring from a tarball or rsync snapshot instead:

tar -xzf /backups/myapp-config-2026-05-23.tar.gz -C /opt/myapp

Handle secrets separately from general app files. Restore the environment file, secret files, SOPS output, or other runtime-only config from the safest available source.

scp /secure-backups/myapp/.env ubuntu@NEW_VPS:/opt/myapp/.env
chmod 600 /opt/myapp/.env

Before proceeding, inspect the Compose file and verify that it references the paths you actually restored:

cd /opt/myapp
docker compose config

This is where many recoveries go sideways. Paths changed, a secret file is missing, or an old compose file still points at a dead external service. Catch those mismatches now instead of after traffic returns.

Step 4: Restore volumes and databases safely

Restore data according to what the application actually stores. Filesystem content and database content are not interchangeable. If you have logical database dumps, prefer them over copying live database files from an uncertain old host.

For uploaded files or bind-mounted assets:

rsync -avz /backups/myapp-uploads/ /srv/myapp-uploads/

For Postgres from a custom-format dump:

# Start only the database service first if your Compose stack separates it
cd /opt/myapp
docker compose up -d db

# Copy the dump if needed, then restore it
scp /backups/myapp_db_2026-05-23.dump ubuntu@NEW_VPS:/tmp/
docker cp /tmp/myapp_db_2026-05-23.dump postgres:/tmp/myapp_db.dump
docker exec -it postgres createdb -U postgres myapp_db_restore || true
docker exec -it postgres pg_restore -U postgres -d myapp_db_restore --clean --if-exists /tmp/myapp_db.dump

For MariaDB or MySQL from a logical dump:

docker compose up -d db
docker exec -i mariadb mariadb -u root -p'MYSQL_ROOT_PASSWORD' myapp_db < /backups/myapp_db.sql

If your app uses named Docker volumes rather than bind mounts, restore them intentionally. One practical pattern is restoring into a temporary container that mounts the target volume, then extracting the backup into that mount.

docker volume create myapp_app_data
docker run --rm \
  -v myapp_app_data:/restore-target \
  -v /backups:/backups \
  alpine sh -c "cd /restore-target && tar -xzf /backups/myapp_app_data.tar.gz"

Only restore raw database files into a volume if you are following the database engine's supported procedure. For most beginners, logical dumps are the safer default.

Step 5: Bring the stack up in a safe order

Start the minimum dependencies first, not the entire application blindly. The usual safe order is:

  1. database
  2. cache or queue if the app depends on it
  3. app containers
  4. background workers
  5. reverse proxy
cd /opt/myapp
docker compose up -d db redis
docker compose ps
docker compose up -d app worker
docker compose up -d nginx

Then inspect health and logs immediately:

docker compose ps
docker compose logs --tail=100 db
docker compose logs --tail=100 app
docker compose logs --tail=100 worker
sudo nginx -t && sudo systemctl reload nginx

If Compose recreates containers and preserves named volumes, that is normal. The key question is whether the restored storage is mounted where the app expects it and whether the app can authenticate to its dependencies with the restored secrets.

Step 6: Verify the recovered stack before reopening traffic

Do not use a single curl response as your success metric. Verify the parts that matter:

  • home page and static assets load
  • logins work
  • uploads or user files are present
  • database-backed records look correct
  • background jobs and workers are connected
  • proxy configuration and HTTPS are valid
curl -I http://NEW_VPS_IP
curl -H 'Host: example.com' http://NEW_VPS_IP
docker compose ps
docker compose logs --tail=100
sudo journalctl -u nginx -n 100 --no-pager

If you can safely use a hosts-file override or private tunnel to test the real domain path before changing DNS, do it. Once traffic returns, your mistakes stop being private.

When the app is actually healthy, update DNS or your upstream proxy route to point at the new VPS. Then continue monitoring for a while instead of declaring victory immediately.

Containment and rollback options

In a true disaster recovery, rollback is sometimes limited because the old host is dead. Still, you usually have containment choices:

  • If the old host is partially alive, leave it untouched until the new stack is verified.
  • If the restore is failing fast, keep traffic on a maintenance page instead of reopening a broken app.
  • If you restored stale data, communicate the recovery point clearly and preserve logs before trying a second restore.

When a recovery goes wrong, the next best move is often to stop making new changes, capture the current state, and return to the last known-good backup rather than improvising ten risky fixes in a row.

Step 7: Confirm the recovery is operationally real

A successful restore should leave you with:

  • a rebuilt VPS with the expected firewall and runtime tools
  • the Compose project and secrets restored cleanly
  • database and file data present where the app expects them
  • a healthy app stack with readable logs
  • traffic pointed to the recovered host only after verification
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 50 --no-pager'

Once the emergency has passed, document the recovery gaps you discovered. If secret storage was messy, backups were incomplete, or restore order was unclear, fix that while the pain is still fresh.

Troubleshooting common restore failures

The app containers start, but the site shows database errors.
The restored credentials may not match the database, the dump may have gone into the wrong database name, or the app started before the database was actually ready.

Uploads are missing even though the app loads.
The bind mount or named volume was restored to the wrong path, or the app expects a different ownership or UID/GID on the files.

Nginx works, but the app still returns 502 errors.
The upstream app may not be reachable on the expected network or port. Check docker compose ps, container logs, and the proxy target.

The restore succeeded, but background jobs are not running.
Worker containers, cron entries, or queue services are often restored last or forgotten entirely. Verify them explicitly.

HTTPS fails after the DNS change.
Confirm ports 80 and 443 are open, the server block matches the real domain, and certificate issuance or restore succeeded on the new host.

What to do next

Once you can restore the app onto a fresh host, the next improvement is rehearsing risky production changes before they happen. Continue with the next recommended topic: cloning production into a safe staging environment or building a maintenance-mode workflow for schema and storage changes.