How to restore a server from backup when the old one is gone
Build a new server on the same OS release, recreate its users and packages, then restore in dependency order: the /etc files you changed (never the whole directory), the databases from their dumps, the application files, and last the services, cron jobs and TLS certificates. Check everything on the new server while it still has no traffic, and only then move DNS. Write each step down as you go: that list is your runbook for next time.
Before you start
If the provider still has a recent snapshot and your account works, create a server from it instead: it brings the OS and everything on the disk back in one step (snapshots versus backups). This guide is for when there is no snapshot, the account or provider is gone, or the snapshot is too old to use.
Gather what the rebuild needs, from somewhere other than the dead server:
- Credentials for the storage that holds the backups.
- The key or passphrase if the backups are encrypted.
- Logins for the cloud account and the DNS provider.
- The backups themselves: file archives, database dumps, the PostgreSQL globals file, and the package, service and firewall lists from the backup checklist.
If the server was broken into, restore from a backup taken before the intrusion, review crontabs, authorized_keys and anything in /usr/local before you copy them back, and rotate every secret instead of restoring the old ones. See protecting backups from attackers.
Rebuilding because the server was compromised? Work out when it started first, so you restore from a backup taken before then: see what to do when your server is hacked.
Need a fresh server to restore onto? A DigitalOcean Droplet is one option.
Create a DigitalOcean accountAffiliate link — we earn a commission if you sign up.
Restore or rebuild: decide per piece
| Piece | Restore or rebuild | Why |
|---|---|---|
| OS, kernel and packages | Rebuild | A fresh image of the same release, then your package list. |
| /etc/fstab, /etc/hostname, network config, /etc/machine-id | Rebuild | They describe the old disk and network. Copied over, they can stop the new server booting or connecting. |
| Users and groups | Rebuild | Recreate them with their old IDs; don't copy /etc/passwd, /etc/shadow or /etc/group over. |
| Config you changed in /etc | Restore | File by file, after a diff. |
| Databases | Restore | From dumps, never from copied data directories. |
Uploads, .env files, user data | Restore | They exist nowhere else. |
| Application code | Either | Restore it, or deploy the commit that was live; rebuild dependencies the backup left out. |
| Docker volumes | Restore | Images are pulled again by tag. |
| TLS certificates | Restore | Reissuing works, but needs DNS first and is rate-limited. |
| Caches, sessions, logs | Skip | The app recreates them. |
The order matters: users before files so owners resolve, databases before the app that reads them, services and cron after the data so nothing runs against an empty database, and traffic last. The runbook at the end lists every step.
Match the OS release and database versions
Restore onto the release the backup came from, and upgrade later as a separate job. Releases change versions under you: Ubuntu 22.04 ships PHP 8.1 and PostgreSQL 14, Ubuntu 24.04 ships PHP 8.3 and PostgreSQL 16. A site file that points at PHP 8.1's FPM socket fails on 24.04, and settings in /etc/postgresql/14 are not where PostgreSQL 16 looks. Read the old release from the /etc archive without extracting it:
tar -xzf etc-2026-10-02.tar.gz -O etc/lsb-release-O writes the file to standard output. Ask for etc/lsb-release on Ubuntu: etc/os-release is a symlink into /usr/lib, so the archive holds only the link and tar prints nothing. On Debian, read etc/debian_version. For PostgreSQL, a custom-format dump's header names the server it came from:
pg_restore --list appdb.dump | grep DumpedIt prints the Dumped from database version and Dumped by pg_dump version lines. PostgreSQL documents that dumps load into newer server versions, while loading one into an older major version may need the dump edited by hand. Install the same major version, and upgrade once the server is back.
With the PostgreSQL project's apt repository added, the unversioned postgresql package installs the newest major release; on our Ubuntu 24.04 server it pointed at PostgreSQL 18. Install the versioned package, such as postgresql-16.
Users before files
Extract the /etc backup into a staging directory, never over /etc:
sudo mkdir -p /root/restoresudo tar -xzf etc-2026-10-02.tar.gz -C /root/restoreList the accounts you created, with their name, UID, GID, home and shell:
sudo awk -F: '$3 >= 1000 && $3 < 65534 {print $1, $3, $4, $6, $7}' /root/restore/etc/passwddeploy 1001 1001 /home/deploy /bin/bash
sam 1002 1002 /home/sam /bin/bashRecreate each group and user with those numbers (groupadd -g, then useradd -u and -g, as in the migration guide) and put back their ~/.ssh/authorized_keys, before any files arrive.
Why first: extracting as root, GNU tar gives each file the owner named in the archive if that name exists on the new system, and the stored number if it does not. In our test, a file archived as www-data with ID 9033 came back owned by the local www-data (33), while a file owned by deploy (1001), extracted before deploy existed, came back owned by the bare number 1001. Creating deploy with UID 1001 then fixes it, no chown needed. This lists files whose owner or group has no name:
sudo find /var/www /srv /home -nouser -o -nogroupRestore /etc file by file
On a fresh install of the same release, most differences between the backed-up /etc and the new one are your own changes. List them:
sudo diff -rq --no-dereference /root/restore/etc /etc | grep -v '^Only in /etc'-r recurses and -q only names the files. --no-dereference compares symlinks as links; without it, links into /run such as resolv.conf printed No such file or directory in our test. The grep drops files that exist only on the new server. What remains: Only in /root/restore/etc/... for files you added, such as a site in /etc/nginx/sites-available, and Files ... differ for files you changed, plus the machine-identity files from the table, which you skip.
Read each diff (sudo diff -u /etc/ssh/sshd_config /root/restore/etc/ssh/sshd_config) and copy what you need with sudo cp -a, which keeps owner, mode and symlinks. Test before reloading: sudo nginx -t checks nginx's syntax and opens the files it names, and sudo sshd -t checks the SSH configuration and keys.
Databases, then files
Install the database server, copy over the settings you changed, then load the dumps and stop at the first error. For PostgreSQL, load the roles first, because pg_dump leaves them out (details):
sudo -u postgres psql -X -v ON_ERROR_STOP=1 -d postgres < /root/restore/globals.sqlsudo -u postgres createdb -T template0 -O appuser appdbsudo -u postgres pg_restore --exit-on-error -d appdb < /root/restore/appdb.dumpON_ERROR_STOP=1 makes psql stop at the first failing statement and exit with status 3. --exit-on-error does the same for pg_restore, which otherwise carries on and counts errors at the end. Your shell opens the < files as root, so the postgres user needs no access to /root. For MySQL or MariaDB, run sudo mysql < /root/restore/appdb.sql without --force, which would continue past errors, and recreate the app's database user: a single-database dump does not include it. More in pg_dump and mysqldump.
Then the files. Check the paths inside an archive, then extract it as root from the directory it was created in:
tar -tzf www-2026-10-02.tar.gz | headsudo tar -xzf www-2026-10-02.tar.gz -C /varRun as root, tar restores owners and permissions by default. For containers, restore the volumes as in Docker volume backups and pull images by the tags your compose file names.
Services, cron and TLS last
- Enable the services on your saved list, start them, and check that
systemctl --failedlists none. - Files in /etc/cron.d came back with /etc. Install each user's crontab from the backup with
sudo crontab -u deploy /root/restore/var/spool/cron/crontabs/deployrather than copying it into the spool. On Ubuntu 24.04,crontab -n filechecks a file's syntax without installing anything. - Make sure the old server cannot come back on its own. If the provider revives it while the new one runs, both run the same jobs and take writes. Power it off, or cut its network at the provider.
For TLS, restore /etc/letsencrypt whole, from its tar archive as root, so the symlinks from live/ into archive/ survive, and install the Certbot plugin that issued the certificates: each one's file in /etc/letsencrypt/renewal names the authenticator and installer it renews with. Restored certificates work at once, before DNS moves. Reissuing instead needs DNS pointed at the new server, unless you use DNS validation, and Let's Encrypt allows only 5 certificates per exact set of names every 7 days.
Check it, then move traffic
The new server has everything but visitors. Prove it works while that is still true:
- The site answers with its real hostname and certificate: curl with
--resolvepoints the name at the new server, as in the restore drill. - You can log in, and a page that reads the database shows recent data.
- The newest row's timestamp, such as
SELECT max(created_at) FROM orders, shows how much data the backup did not have. - Outgoing email works, and a scheduled job has run.
If the old address was movable (a reserved IP on DigitalOcean or Vultr, a floating IP on Hetzner, an Elastic IP on AWS), reassign it and traffic moves without waiting for DNS. Otherwise change the DNS records: resolvers that cached the old address keep it until the record's TTL runs out, and you cannot shorten a TTL after the fact. dig +short example.com @1.1.1.1 shows what a public resolver answers. DNS failover makes this faster next time.
Then finish: run sudo certbot renew --dry-run, confirm the new server's first backup completes, and write down how long the rebuild took and how old the newest restored data was. Those are your real RTO and RPO.
Common problems
| Symptom | Cause and fix |
|---|---|
pg_restore: error: unsupported version (1.16) in file header | The pg_restore client is older than the pg_dump that wrote the archive. Install the same or newer major version's client. |
role "appuser" does not exist | The globals file was not loaded first. Load it, drop the half-restored database and restore again. |
ls -l shows numbers instead of owner names | The user did not exist when tar ran. Create it with its old UID. |
| nginx returns 502 | The app server or PHP-FPM is not running, or the site file names a socket from another version. |
| Scheduled jobs never run | The crontab was copied into the spool instead of installed with crontab -u, or cron is not enabled. |
Write it down: the runbook
Copy this, fill in the blanks for each server, and keep it somewhere you can reach without that server:
# Rebuild runbook: web-01 Last tested: ____ Took: ____
Backups: s3://example-backups/web-01/ Encryption key kept in: ____
Release: Ubuntu 24.04 PostgreSQL 16 PHP 8.3
Logins: cloud ____ storage ____ DNS ____ (kept in: ____)
[ ] 0 Old server powered off or isolated start time: ____
[ ] 1 New server: same release, size >= ____, region ____
[ ] 2 Packages from packages-manual.txt; firewall rules
[ ] 3 Groups and users with old IDs: deploy 1001:1001, ____
[ ] 4 authorized_keys back for each user
[ ] 5 /etc archive -> /root/restore; diff; copied: ____
[ ] 6 nginx -t and sshd -t pass
[ ] 7 postgresql-16; globals.sql; createdb; pg_restore --exit-on-error
[ ] 8 File archives extracted; find -nouser -o -nogroup prints nothing
[ ] 9 Services enabled; crontabs installed; systemctl --failed empty
[ ] 10 /etc/letsencrypt restored; certbot plugin: ____
[ ] 11 curl --resolve returns 200; login works; newest row: ____
[ ] 12 IP reassigned or DNS changed; dig shows the new address
[ ] 13 certbot renew --dry-run passes
[ ] 14 First backup of the new server completed
[ ] 15 RTO: ____ RPO: ____ Missing or wrong in this runbook: ____Then run it on a spare server before you need it. A restore drill is how you learn the globals file was never backed up while that is still cheap to fix, and the disaster recovery plan has a place to keep the result.
Frequently asked questions
- Can I restore a server backup to a different cloud provider?
- Yes, if the backups are file archives and database dumps stored outside the old account. Provider snapshots generally restore only within that provider. The steps are the same; only the first one changes.
- Should I restore the whole /etc directory?
- No. Extract it to a staging directory, compare it with the new server's /etc, and copy only the files you changed. /etc/fstab, the network config, /etc/hostname and /etc/machine-id describe the old machine.
- Can I restore onto a newer OS release?
- You can, but then you are upgrading in the middle of an outage: new PHP, database and library versions, with config written for the old ones. Restore onto the same release, then upgrade when nothing is down.
- How long does it take to rebuild a server from backups?
- Mostly as long as the database restore and the file download take, plus whatever you have to work out on the spot. The only reliable figure is one you measured: run the runbook on a spare server and time it.
How this was checked
Commands, limits and prices were checked against these official pages, on October 4, 2026:
- Debian tar(1) manual (--same-owner, --numeric-owner, -p, -O)
- Debian diff(1) manual
- Debian find(1) manual (-nouser, -nogroup)
- Debian crontab(1) manual (-u, -n)
- PostgreSQL documentation: pg_dump (version compatibility)
- PostgreSQL documentation: pg_restore
- PostgreSQL documentation: psql (ON_ERROR_STOP, exit status)
- MySQL 8.4 Reference Manual: mysql client options
- Ubuntu packages: postgresql in noble
- Ubuntu packages: postgresql in jammy
- Ubuntu packages: php-fpm in noble
- Ubuntu packages: php-fpm in jammy
- nginx: command-line parameters
- OpenSSH sshd(8) manual
- Certbot User Guide
- Let's Encrypt: Rate Limits