Articles › Servers and DevOps

3-2-1 backups for a small VPS, and why you test the restore every month

A backup you never restored is a hope, not a plan. 3-2-1 and 3-2-1-1-0, what to back up on a VPS, restic encryption, retention and storage math, a monthly restore drill.

Beginner 7 min read October 22, 2026

What you will learn
  • 3-2-1 means three copies, on two kinds of media, at least one off-site; 3-2-1-1-0 adds one copy that cannot be deleted and zero errors on verification
  • On a VPS, back up database dumps, user uploads and config, not the operating system
  • restic encrypts everything before it leaves the server; retention is forget --prune, verification is check --read-data-subset
  • Restore to a blank machine every month and time it; that number is your real RTO

A backup you have never restored is a hope, not a plan. Most backups fail quietly: the cron job stopped three months ago, the dump came out empty, or the password lived on the machine that just died. This article covers backing up a small server so you can actually get it back: the 3-2-1 rule, what to back up, encryption, storage math, and a monthly restore drill.

Checked against the restic 0.19 (stable) docs, BorgBackup 1.4.5, PostgreSQL 18 pg_dump, Veeam’s best-practice guide and NIST SP 800-34. Commands are for Ubuntu/Debian.

The 3-2-1 and 3-2-1-1-0 rules

NumberMeaningOn a small VPS
3three copies of the data (the live one + two backups)the server + provider snapshots + a restic repository
2on two kinds of mediaVPS disk and object storage
1one copy off-siteobject storage at a different provider, different account
1one copy offline, air-gapped or immutablea bucket with object lock, or another machine that pulls the backup
0zero errors when you verifyrestic check + a monthly restore drill

3-2-1-1-0 is the extension Veeam uses, and the last two digits answer today’s threats directly. If an attacker or a runaway script gets root on your server, it can use the same credentials your backup uses to delete the backups. A copy that cannot be deleted is the way out. The zero is what this article is about.

DigitMeaning33 copies of the data2on 2 kinds of media11 copy off-site11 copy immutable/offline00 errors on restore
The last two digits are what make a backup restorable on the day you are breached or broken

What to back up, and what not to

Back up what you cannot recreate. Skip what you can reinstall.

Back upSkip
database dumps (pg_dump, SQLite VACUUM INTO)OS files, /usr, packages
user uploadsnode_modules, venv, build output
config: /etc/nginx, systemd units, crontabs, .envcaches, old logs, Docker images
your package list and setup scriptsthe DB data directory while it runs

You can reinstall the OS in minutes; a file a user uploaded last year cannot be recreated. Never copy the PostgreSQL data directory while it runs. Use pg_dump, which makes a consistent copy and does not block anyone. On SQLite, see the backup section of the SQLite article.

Encryption with restic

restic encrypts everything on the server before it leaves (AES-256 in CTR mode with a Poly1305-AES MAC, keys derived from your password with scrypt). The storage provider sees only unreadable blobs. restic also deduplicates, so a new snapshot stores only what changed. BorgBackup works along similar lines; pick one. This article uses restic.

One-time setup on the server (S3-compatible storage in this example):

# /root/.restic-env  (chmod 600)
export RESTIC_REPOSITORY="s3:https://s3.example.com/my-backups"
export RESTIC_PASSWORD_FILE=/root/.restic-pass
export AWS_ACCESS_KEY_ID="EXAMPLE_KEY_ID"
export AWS_SECRET_ACCESS_KEY="EXAMPLE_SECRET"
source /root/.restic-env
restic init

Lose the password and the data is gone for good. Keep a copy in a password manager off the server, and use an access key scoped to this one bucket, not your account’s main key (the same reasoning as one credential per job).

The nightly backup script

#!/usr/bin/env bash
# /usr/local/bin/backup.sh  run as root nightly from cron or a systemd timer
set -euo pipefail
source /root/.restic-env

mkdir -p /var/backups/db
sudo -u postgres pg_dump -Fc appdb > /var/backups/db/appdb.dump

restic backup /var/backups/db /srv/app/uploads /srv/app/.env \
              /etc/nginx /etc/systemd/system /var/spool/cron/crontabs

restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
restic check --read-data-subset=5%

-Fc is the custom format, compressed by default, restored with pg_restore. set -e stops the script the moment any step fails. For alerts on failure, use the same approach as the Telegram server monitor. A backup that fails silently is worse than none, because it makes you believe you have one.

forget only removes snapshot records; the data goes when prune runs, which --prune does right away. check --read-data-subset=5% downloads and decrypts a random 5% of the data every night. Twenty nights add up to the size of the repository, but because it is random, some packs may never be checked. For guaranteed coverage, use groups instead: 1/20 through 20/20, one group a night. The borg equivalent is borg check --verify-data.

Retention math: how many snapshots, how much storage

Policy --keep-daily 7 --keep-weekly 4 --keep-monthly 6, one backup a day.

restic checks each snapshot against every rule, keeps it if any rule matches, and counts it once.

RuleKeepsOverlap with earlier rulesActually added
daily 7the last 7 days-7
weekly 4the latest in each of 4 weeksthis week is already a daily3
monthly 6the latest in each of 6 monthsthis month is already a daily5
Total≈ 15 (at most 7 + 4 + 6 = 17)

About six months of history from roughly 15 snapshots.

Storage. Say the server has a 1 GB database dump (already compressed) and 8 GB of uploads.

  • Uploads: deduplication means unchanged files add almost nothing per snapshot. About 8 GB, plus files deleted in the last six months (still held by older snapshots)
  • Dumps: a small change in a compressed file shifts the bytes throughout, so deduplication helps little. Worst case, every one is new: 15 × 1 GB = 15 GB
  • Worst-case total: 8 + 15 = ≈ 23 GB, plus whatever was deleted in that window

Check the real figure with restic stats --mode raw-data and multiply by your storage’s per-GB price (prices change; check the provider’s pricing page when you set it up).

RPO and RTO in plain words

  • RPO (recovery point objective) = how much recent data you can afford to lose. NIST defines it as the point in time to which data must be recovered. With a backup at 02:00 every night, a crash at 01:59 the next night loses almost 24 hours, so your RPO is 24 hours
  • RTO (recovery time objective) = how long the system can stay down before it is usable again

An RTO example: restoring only the latest snapshot means 8 GB + 1 GB = 9 GB. At 50 MB/s (an assumption; measure yours) that is 9,000 ÷ 50 = 180 seconds = 3 minutes. But that is only the download. You still need to build the server, install packages, run pg_restore and switch DNS. The real number only comes from a drill.

An RTO on paper is a hope; your real RTO is the time you measured at the last restore drill.

The monthly restore drill

Use a blank machine (a throwaway VPS or a local VM), never production, and follow your written notes, not memory.

# on the drill machine, after installing restic and PostgreSQL
source /root/.restic-env          # password from the password manager, not from the old server
restic snapshots
restic restore latest --target /tmp/drill

sudo -u postgres createdb drill_test
sudo -u postgres pg_restore -d drill_test /tmp/drill/var/backups/db/appdb.dump
sudo -u postgres psql -d drill_test -c "SELECT count(*) FROM users"
ls /tmp/drill/srv/app/uploads | head

restic restores files under --target with their original paths, hence /tmp/drill/var/backups/.... The checklist every month:

  1. The restore worked using only things stored off the server (password, keys, notes)
  2. Row counts in the main tables are close to production
  3. A few random uploads actually open
  4. Note the start-to-finish time. That is this month’s RTO. If it keeps growing, the data has outgrown the plan

Common mistakes

  • The repository password exists only on the server. The server dies and the backup cannot be opened
  • Nobody notices cron stopped. Alert on failure and on the job not running at all
  • The dump came out empty or truncated, but the script has no set -euo pipefail, so it carries on and happily backs up an empty file
  • Using the cloud account’s main key. If it leaks, the whole account leaks, and anyone with access to the server can delete every backup
  • Backing up everything, OS included. More storage, slower restores, and nothing you could not reinstall
  • Never rehearsing. You discover on the real day that .env was missing, or the dump came from the wrong database

Sources

FAQ

Are my VPS provider's snapshots enough?

They are one good copy, but they usually live with the same provider under the same account. If the account is locked or deleted, the snapshots go with it. Count them as one of the three copies, not as the off-site one.

Can I just copy the PostgreSQL data directory?

Not while the database is running; the files you get may not be consistent with each other. Use pg_dump, which produces a consistent copy without blocking readers or writers, and back up the dump file.

I lost my restic repository password. Can I recover?

No. The restic docs say plainly that losing the password means the data is irrecoverably lost. Always keep a copy of the password in a password manager off the server.

Commands and settings were tested on sample systems. Try them on a test machine before production.