VPS Snaps

How to test a backup restore: files, PostgreSQL and a full server

To test a backup, restore it somewhere harmless and check that the result is complete and usable: the files match their checksums, the database has every table and the rows you expect, and a server built from the snapshot boots and serves your app. A backup job that reports success only proves that something was written. The three drills below catch the failures that otherwise show up in the middle of an outage.

10 min readUpdated Tested on Ubuntu 24.04 with GNU tar 1.35, GNU coreutils 9.4, diffutils 3.10 and PostgreSQL 16.15

Why untested backups fail

Each of these produces a backup file every night and a green status, until the day you need it:

FailureHow it happensWhat catches it
Empty dumpspg_dump appdb | gzip > appdb.sql.gz in cron cannot connect, but the line exits 0 because a pipeline returns gzip's status. You get a 20-byte file every night.set -o pipefail, a size check, a restore
Truncated filesAn upload or download stops partway. pg_restore then fails with could not read from input file: end of file.A checksum after download, a restore
Missing rolespg_dump dumps one database. Roles and tablespaces are cluster-wide and are not in it, so ownership and grants fail on a new server.pg_dumpall --globals-only, a restore on a clean machine
Wrong ownership or permissionsGNU tar restores owners only when run as root. As a normal user, every file becomes yours and the app cannot write its uploads.Extracting as root, checking with stat
Lost encryption keysThe key or passphrase was only on the server you just lost.Running the drill from a machine that does not have the key
Version mismatchA dump made by a newer pg_dump cannot be read by an older pg_restore: unsupported version (1.16) in file header.Restoring with the tool versions you would really use
Missing pathsUploads on a separate volume, TLS certificates or cron files were never in the backup.A full-server drill, diff -rq against the live server

One more trap: by default pg_restore keeps going after errors and ends with pg_restore: warning: errors ignored on restore: 12. It does exit with status 1, but a script that does not check the status reports success. Use --exit-on-error in drills.

Before you start: make backups checkable

Two small additions to your backup job make every later check faster. Say the job archives /srv/app into /backups:

Terminal
tar -czf /backups/app-2026-10-03.tar.gz -C /srv app

Store a checksum of the archive next to it. Run it from inside /backups so the file name inside the .sha256 file has no directory in front of it; otherwise the check fails on any other machine.

Terminal
cd /backups
Terminal
sha256sum app-2026-10-03.tar.gz > app-2026-10-03.tar.gz.sha256

Then record a checksum for every file in the source, from inside the directory so the paths are relative:

Terminal
cd /srv/app
Terminal
find . -type f -exec sha256sum {} + > /backups/app-2026-10-03.sha256

To spot empty or near-empty backups, list files under 1,024 bytes, skipping the small checksum files:

Terminal
find /backups -type f ! -name '*.sha256' -size -1024c

Do not write -size -1k. find rounds sizes up to whole units, so -size -1k matches only files of 0 bytes and misses the 20-byte empty gzip.

Drill 1: restore files from a tar archive

Copy the archive and both .sha256 files from your backup storage into /backups on the test machine, then check that the archive arrived intact:

Terminal
cd /backups
Terminal
sha256sum -c app-2026-10-03.tar.gz.sha256

It prints app-2026-10-03.tar.gz: OK. Extract into a scratch directory, never over the live path. Run tar as root so owners and permissions come back:

Terminal
mkdir -p /tmp/restore-test
Terminal
tar -xzpf app-2026-10-03.tar.gz -C /tmp/restore-test

Check every file against the manifest. --quiet prints only failures, so no output means every file matched:

Terminal
cd /tmp/restore-test/app
Terminal
sha256sum -c --quiet /backups/app-2026-10-03.sha256

A damaged file shows up like this, and the command exits with status 1:

Output
./public/uploads/img3.bin: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match

If you are testing on the live server, compare the restore with the live directory to find anything the backup never included:

Terminal
diff -rq /srv/app /tmp/restore-test/app
Output
Files /srv/app/public/index.php and /tmp/restore-test/app/public/index.php differ
Only in /srv/app/public/uploads: new-since-backup.bin

Files changed or added since the backup are expected. Whole directories listed as Only in /srv/app are not: they are paths your backup skips. Last, spot-check ownership and mode on a file that matters:

Terminal
stat -c '%U:%G %a %n' /tmp/restore-test/app/config/.env

Expect www-data:www-data 600 /tmp/restore-test/app/config/.env, or whatever your app needs. Then clean up:

Terminal
rm -rf /tmp/restore-test

Drill 2: restore a PostgreSQL dump into a scratch database

This assumes custom-format dumps made with sudo -u postgres pg_dump -Fc appdb > /backups/appdb-2026-10-03.dump. First, read the archive's table of contents. This needs no database and fails at once on a truncated or corrupt file:

Terminal
pg_restore --list /backups/appdb-2026-10-03.dump | grep 'TABLE DATA'
Output
3419; 0 42621 TABLE DATA public audit_log postgres
3415; 0 42599 TABLE DATA public customers postgres
3417; 0 42609 TABLE DATA public orders postgres

Create an empty scratch database from template0, as the PostgreSQL docs recommend:

Terminal
sudo -u postgres createdb -T template0 appdb_restore_test

Restore into it and time it. The < redirect is opened by your shell, so the postgres user does not need read access to /backups. The time is your database restore time.

Terminal
time sudo -u postgres pg_restore --exit-on-error -d appdb_restore_test < /backups/appdb-2026-10-03.dump

On a machine that does not have your roles, either load your pg_dumpall --globals-only file with psql first, or add --no-owner --no-acl for a data-only check. If loading the globals file fails, you found a gap before it mattered. The globals file holds password hashes, so store it as carefully as the dumps.

Count the tables:

Terminal
sudo -u postgres psql -X -At -d appdb_restore_test -c "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"

Then count rows in every table. Save this query as row-counts.sql. It runs a real count(*) per table; the row estimates in pg_stat_user_tables are not exact enough for this.

row-counts.sql
SELECT table_schema || '.' || table_name AS table,
       (xpath('/row/n/text()',
              query_to_xml(format('SELECT count(*) AS n FROM %I.%I', table_schema, table_name),
                           false, true, '')))[1]::text::bigint AS row_count
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
  AND table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1;

Run it against the restore and against the live database, then compare:

Terminal
sudo -u postgres psql -X -At -d appdb_restore_test < row-counts.sql > restored-counts.txt
Terminal
sudo -u postgres psql -X -At -d appdb < row-counts.sql > live-counts.txt
Terminal
diff live-counts.txt restored-counts.txt
Output
3c3
< public.orders|4203
---
> public.orders|4200

A few more rows in live tables that take writes is normal. A missing table, or a table at 0 that is not empty in production, is a failed drill. Now check how fresh the data is:

Terminal
sudo -u postgres psql -X -At -d appdb_restore_test -c "SELECT max(created_at) FROM orders"

The gap between that timestamp and now is how much data you would lose if the server died at this moment. Drop the scratch database when you are done:

Terminal
sudo -u postgres dropdb appdb_restore_test

Drill 3: restore a full server from a snapshot

  1. Pick the snapshot you would use in a real outage, usually the newest, and note when it was taken.
  2. Create a new server from it in your provider's console or CLI, the same size or larger.
  3. When you create it, attach a firewall that allows SSH from your IP only and blocks outbound traffic.
  4. Leave DNS alone, and run the checks below on the new server itself.
  5. Run the smoke tests, then use the app: log in as a test user, open a page that reads from the database, upload a file.
  6. Write down the time from step 2 to a working app. That is your full-server restore time.
  7. Destroy the server, its volumes and any reserved IP, so you stop paying for them.

A clone of production behaves like production: same cron jobs, same API keys, same mail settings, same queue workers. Without the outbound block it can charge cards, send emails to customers or write to shared services.

On the new server, list any services that failed to start:

Terminal
systemctl --failed

Request your site locally with its real hostname, so TLS and virtual hosts are tested too. -f makes curl exit non-zero (22) on an HTTP error; it prints the status code either way:

Terminal
curl -fsS -o /dev/null -w '%{http_code}\n' --resolve example.com:443:127.0.0.1 https://example.com/

And confirm the database holds recent data:

Terminal
sudo -u postgres psql -X -At -d appdb -c "SELECT max(created_at) FROM orders"

What to record each time

Two numbers matter. NIST defines the recovery point objective (RPO) as "the point in time to which data must be recovered after an outage": how much recent data you can afford to lose. The recovery time objective (RTO) is how long systems can stay in recovery before the business is hurt. A drill measures what you actually achieve, so you can compare it with those targets.

FieldExample
Date and who ran it2026-10-03, Sam
Backup usedappdb-2026-10-03.dump, created 02:00 UTC
What was restored, and wherePostgreSQL appdb into a scratch database on the same server
ResultPass, or fail with the first error
RTO achieved6 min 40 s from start to a working app
RPO achievedNewest order 01:59 UTC, 9 hours old at test time
Problems and fixesGlobals file missing; added pg_dumpall to the backup job

How often to test

CheckHow oftenAlso after
Size and checksumEvery backup, automatedAny change to the backup job
Database restoreWeekly, automatedA database major version upgrade
File restoreMonthlyMoving data to a new disk or volume
Full serverQuarterlyOS upgrades, a new backup tool or storage, big app changes

Automate the database drill

This script restores one dump into a scratch database, checks one table against a floor you know it is above, prints one line, and drops the database even if a step fails. Change SCRATCH_DB, MIN_ORDERS and the orders queries to fit your schema.

/usr/local/bin/restore-drill.sh
#!/usr/bin/env bash
# Restore one PostgreSQL dump into a scratch database, check it, drop it.
# Usage: restore-drill.sh /backups/appdb-2026-10-03.dump
set -euo pipefail

DUMP="$1"
SCRATCH_DB="appdb_restore_test"
MIN_ORDERS=1000   # a floor the real table is always above

sudo -u postgres dropdb --if-exists "$SCRATCH_DB"
trap 'sudo -u postgres dropdb --if-exists "$SCRATCH_DB"' EXIT
sudo -u postgres createdb -T template0 "$SCRATCH_DB"

start=$(date +%s)
# --no-owner/--no-acl: a data check that also works on a machine without your roles
sudo -u postgres pg_restore --exit-on-error --no-owner --no-acl -d "$SCRATCH_DB" < "$DUMP"
seconds=$(( $(date +%s) - start ))

orders=$(sudo -u postgres psql -X -At -d "$SCRATCH_DB" -c "SELECT count(*) FROM orders")
newest=$(sudo -u postgres psql -X -At -d "$SCRATCH_DB" -c "SELECT max(created_at) FROM orders")

if [ "$orders" -lt "$MIN_ORDERS" ]; then
  echo "FAIL: orders has $orders rows, expected at least $MIN_ORDERS" >&2
  exit 1
fi

echo "$(date -u +%FT%TZ) OK dump=$DUMP restore_seconds=$seconds orders=$orders newest_order=$newest"
Terminal
chmod +x /usr/local/bin/restore-drill.sh
Terminal
/usr/local/bin/restore-drill.sh /backups/appdb-2026-10-03.dump

A truncated dump stops it with pg_restore: error: could not read from input file: end of file and exit status 1. A short table prints the FAIL line. Either way the scratch database is dropped. Run it weekly from cron on the newest dump downloaded from your off-site storage, not on a copy that never left the server.

Alert on silence, not only on failure. Have the job ping a heartbeat monitor only when it prints OK, so a drill that stopped running raises an alarm too.

Time the restore while you test it: that number is your real recovery time. See RPO and RTO explained, and write the steps into a disaster recovery plan.

Frequently asked questions

How do I test a backup without touching production?
Restore into a scratch directory, a scratch database name, or a throwaway server, never over the live path. For full-server drills, block outbound traffic so the clone cannot send email or call payment and partner APIs.
Is checking that the backup file exists enough?
No. A failed pg_dump piped into gzip still writes a valid 20-byte file every night. Check sizes and checksums on every backup, and restore regularly.
Can I run restore drills on the production server?
File and database drills, yes, into scratch paths and database names, if the server has the disk space and spare capacity. Full-server drills need a separate machine. A restore on the same server also hides missing roles and paths, so run a drill on a clean machine at least quarterly.
What is the difference between RPO and RTO?
RPO is how much recent data you can afford to lose, measured in time. RTO is how long recovery can take. A drill tells you the real values: the age of the newest data in the restore, and the time the restore took.

How this was checked

The commands were run on Ubuntu 24.04 with GNU tar 1.35, GNU coreutils 9.4, diffutils 3.10 and PostgreSQL 16.15 on October 3, 2026. Any that need something this test server does not have, such as a second server, a cloud account or another database engine, were checked against the official pages below instead.

Sources, on October 3, 2026: