Practical 3-2-1 Backups for VPSs with Restic and Object Storage

Photo by Glen Carrie

Photo by Glen Carrie
VPS providers are reliable right up until they are not. Disks die, accounts get suspended over billing hiccups, datacenters have fires — the 2021 OVH Strasbourg fire turned a lot of single-copy backup strategies into postmortems overnight. The uncomfortable question for anyone running production workloads on rented servers is simple: if this VPS evaporated right now, how many hours of data would I lose, and how long until I am serving traffic again?
My answer to that question is the 3-2-1 rule implemented with restic and S3-compatible object storage, and it costs me only a few dollars a month across multiple servers. This post is the full setup: what the rule actually demands, the exact scripts I run nightly, how databases need special handling, what it costs, and the restore drill that separates a backup system from a feeling of safety.
The rule, popularized by Backblaze and photographer Peter Krogh before them, is a minimum bar, not a gold standard:
3
Three copies of your data — the live production data plus two backups.
2
Two different storage media or systems, so one class of failure cannot take both backups.
1
One copy off-site — a different building, provider, or region than production.
For a VPS fleet, my translation is: the live data on the server, a nightly restic snapshot in object storage at a different provider, and a second restic copy synced to a disk at home or office. Ransomware-era refinements like 3-2-1-1-0 add an offline or immutable copy and zero verification errors — and the verification part, at least, is non-negotiable in my setup, as you will see in the script below.
Backing up an entire VPS image is the lazy default and it is mostly waste — the OS and packages are reproducible from your Ansible playbooks in minutes. I back up only what cannot be rebuilt:
Never snapshot a running database's data directory with a file-level tool. PostgreSQL files copied mid-write are inconsistent and may not start, and the failure is silent until restore day. Dump first — pg_dump or pg_dumpall for Postgres, mysqldump or mariadb-dump for MySQL — then let restic back up the dump. The dump is the backup; restic is the transport and archive.
Restic is my tool of choice because it checks every box at once: client-side encryption by default, content-defined deduplication that keeps incremental snapshots tiny, and native support for S3-compatible backends — AWS S3, Google Cloud Storage, Backblaze B2, MinIO, Wasabi — plus SFTP and rclone for everything else. Setup is a one-time init:
# one-time setup: encrypted repo on S3-compatible storage
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export RESTIC_REPOSITORY=s3:https://storage.googleapis.com/my-backup-bucket
export RESTIC_PASSWORD_FILE=/root/.restic-password
restic initThe nightly job is where the actual strategy lives. Mine is a short shell script driven by a systemd timer, and every line earns its place:
#!/usr/bin/env bash
# /usr/local/bin/backup.sh — runs nightly via systemd timer
set -euo pipefail
# 1. dump databases to files restic can snapshot
pg_dumpall -U postgres | gzip > /var/backups/pg/all.sql.gz
# 2. snapshot app data + dumps (deduplicated, encrypted)
restic backup /var/backups/pg /srv/app/uploads /etc \
--exclude="*.tmp" --tag nightly
# 3. enforce retention, prune unreferenced data
restic forget --keep-daily 7 --keep-weekly 4 \
--keep-monthly 6 --prune
# 4. verify a sample of the repo actually restores
restic check --read-data-subset=5%
# 5. heartbeat to Uptime Kuma — silence means broken
curl -fsS https://status.example.com/api/push/abc123 > /dev/nullSteps four and five are the ones most setups skip. The check with read-data-subset downloads and cryptographically verifies a random 5 percent of repository data every night, so silent corruption gets caught within weeks, not at restore time. The Uptime Kuma heartbeat at the end inverts the alerting: I do not get notified when backups succeed, I get paged when the success signal stops arriving — which also catches the failure mode where cron itself died.
Here is how the three copies land across providers for a typical client setup, with monthly cost for roughly 50 GB of deduplicated backup data:
| Copy | Where it lives | Monthly cost (approx.) |
|---|---|---|
| Copy 1 — live | The production VPS itself: PostgreSQL, uploads, configs. | Included in the server you already pay for. |
| Copy 2 — off-site, different medium | Restic repository in object storage at a different provider and region than the VPS. | A few dollars at typical object-storage pricing around half a US cent per GB. |
| Copy 3 — second medium, second location | restic copy of the same repo to a disk at the office, synced weekly. | Hardware you own; effectively free after purchase. |
Deduplication changes the economics more than people expect: my repositories hold months of nightly snapshots of a 40 GB dataset in roughly 55 GB of storage, because unchanged blocks are stored once. The marginal cost of keeping six months of history instead of one month is nearly zero — so keep the history.
An untested backup is a hypothesis. Twice a year, per server, I run the full drill against a throwaway VPS and time it:
Write the restore commands into the same repo as the backup script, as a runnable restore.sh. During an actual incident you will be stressed and possibly not the person doing the restore. A script that worked at the last drill beats documentation every time.
A real 3-2-1 setup for a VPS is one evening of work: restic init against an object storage bucket, a twenty-line nightly script with dump, snapshot, retention, verification, and heartbeat, plus a second copy somewhere you physically control. The cost rounds to a coffee per month, and the payoff is that the worst infrastructure day of your year — provider fire, ransomware, fat-fingered rm — becomes a documented, rehearsed, two-hour restore instead of a career event. Back up like the disk is already failing, because somewhere in your fleet, it is.
Sources and further reading