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.
- 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
| Number | Meaning | On a small VPS |
|---|---|---|
| 3 | three copies of the data (the live one + two backups) | the server + provider snapshots + a restic repository |
| 2 | on two kinds of media | VPS disk and object storage |
| 1 | one copy off-site | object storage at a different provider, different account |
| 1 | one copy offline, air-gapped or immutable | a bucket with object lock, or another machine that pulls the backup |
| 0 | zero errors when you verify | restic 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.
What to back up, and what not to
Back up what you cannot recreate. Skip what you can reinstall.
| Back up | Skip |
|---|---|
database dumps (pg_dump, SQLite VACUUM INTO) | OS files, /usr, packages |
| user uploads | node_modules, venv, build output |
config: /etc/nginx, systemd units, crontabs, .env | caches, old logs, Docker images |
| your package list and setup scripts | the 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.
| Rule | Keeps | Overlap with earlier rules | Actually added |
|---|---|---|---|
| daily 7 | the last 7 days | - | 7 |
| weekly 4 | the latest in each of 4 weeks | this week is already a daily | 3 |
| monthly 6 | the latest in each of 6 months | this month is already a daily | 5 |
| 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:
- The restore worked using only things stored off the server (password, keys, notes)
- Row counts in the main tables are close to production
- A few random uploads actually open
- 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
.envwas missing, or the dump came from the wrong database
Sources
- Preparing a new repository — restic documentation
- Removing backup snapshots (forget, prune) — restic documentation
- Working with repositories (check) — restic documentation and Restoring from backup
- References (crypto design) — restic documentation
- borg check — BorgBackup 1.4.5 documentation
- pg_dump — PostgreSQL 18 Documentation
- 3-2-1-1-0 Rule — Veeam Best Practice Guide
- Recovery point objective — NIST CSRC Glossary and Recovery time objective
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.