DEV Community

Cover image for Restic Backup for a Small VPS: Script, Retention and Restore Test
Qasim Parray
Qasim Parray

Posted on Originally published at abrarqasim.com

Restic Backup for a Small VPS: Script, Retention and Restore Test

A backup you have never restored is a hope, not a backup. I have said that sentence to clients so many times that it has stopped sounding like advice and started sounding like a tic. Yet when I open a new server for someone, the backup situation is usually one of two things: nothing, or a nightly tar job that has been filling a disk since 2023 and has never been opened.

If you run a small VPS and want a restic backup setup that you can set up in an evening and trust afterwards, this is the one I'd use. I'll cover what restic is doing under the hood in just enough detail to make the commands make sense, then the backup script, the retention policy, a Postgres dump piped straight in, and the part everyone skips: checking and restoring.

I should be upfront about scope. This is for small self-hosted setups, one or a few servers, data measured in gigabytes. If you run a database cluster with point-in-time recovery requirements, a file-level tool is the wrong layer for the database and you should look at WAL archiving instead. For everything else on the box (uploads, configs, compose files, a database dump), restic is a calm choice.

Why restic suits a small VPS

Restic stores your data as encrypted, deduplicated snapshots in a repository. Deduplication means a second backup of mostly unchanged files transfers and stores very little, which is why you can run it nightly without watching your storage bill climb. The repository can sit on a local disk, an SFTP server, or an object store, and the restic documentation on preparing a repository lists the supported backends.

I put the repository somewhere that is not the server being backed up. That sounds obvious, and I have still seen backups stored on the same disk as the data. A disk failure, a ransomware incident or a fat-fingered rm -rf takes both. A storage box or object bucket from a different provider costs a few dollars a month and removes that whole category of failure.

Initialise once:

export RESTIC_REPOSITORY="sftp:backup@storagebox.example.com:/restic/myserver"
export RESTIC_PASSWORD_FILE="/etc/restic/password"

restic init
Enter fullscreen mode Exit fullscreen mode

Keep the password file readable by root only, and keep a second copy of the password somewhere that is not that server, such as your password manager. Restic encrypts the repository, so losing the password means losing every backup. I have not had to test how final that is, and I don't plan to.

The backup script

Here is the script I start from. It is plain Bash on purpose.

#!/usr/bin/env bash
set -euo pipefail

export RESTIC_REPOSITORY="sftp:backup@storagebox.example.com:/restic/myserver"
export RESTIC_PASSWORD_FILE="/etc/restic/password"

# 1. Files: configs, uploads, compose projects
restic backup \
  --tag files \
  --one-file-system \
  --exclude-file /etc/restic/excludes.txt \
  /etc /srv /home/deploy

# 2. Database: dump straight into the repository, no temp file
restic backup \
  --tag postgres \
  --stdin-from-command \
  --stdin-filename myapp.sql \
  -- sudo -u postgres pg_dump --format=plain myapp

# 3. Retention
restic forget \
  --tag files --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
restic forget \
  --tag postgres --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune
Enter fullscreen mode Exit fullscreen mode

A few choices in there are worth explaining.

The --one-file-system flag. The restic backup documentation says it stops restic from crossing filesystem boundaries and subvolumes. On a VPS that keeps it from wandering into mounted volumes, network shares or /proc-style virtual filesystems you never meant to back up. If you do have a second volume with data you want, list its mount point explicitly as another path.

The exclude file. Things like node_modules, build caches, *.log, and Docker's own storage directory should stay out. You back up your compose files and volumes, not /var/lib/docker/overlay2. The same docs page describes --exclude-file as a file listing items to skip, one pattern per line.

The Postgres dump. The same documentation explains that --stdin-from-command makes restic run a command and store its output, and that you must put -- before the command so restic doesn't parse its arguments. That removes the temp file I used to write to disk, which was always a second copy of the database sitting around waiting to be forgotten. My own bit of caution here: a plain-format dump of a large database is slow to restore. For anything past a few gigabytes I'd switch to pg_dump --format=custom and restore with pg_restore, which also lets you restore a single table.

Tags. I tag file snapshots and database snapshots differently, then apply different retention to each. The docs mention tags as a way to attach identifying information and to select snapshots in forget, and I use them for exactly that.

Retention without surprises

Retention is where I've seen people shoot themselves. The restic forget documentation explains the policy flags: --keep-daily n keeps the most recent snapshot for each of the last n days that have snapshots, with matching weekly and monthly flags. It also notes that forget --prune removes the data that was referenced only by the snapshots you dropped, which can take a while on a large repository.

Two cautions from that documentation shape how I use it. First, a policy applies to snapshots grouped by host and paths by default, so if you change your backup paths later, you create a new group, and the old one is no longer being pruned by the same rules. I run restic snapshots after any path change and look at what groups exist. Second, run forget with --dry-run the first time. Reading the list of what would be removed costs nothing.

My defaults are a week of dailies, a month of weeklies and half a year of monthlies for files, and a bit longer for database dumps, since those are the thing clients ask for after a mistake. Adjust for what your data is worth and how long you'd take to notice a problem. If a bug quietly corrupts data and you notice a month later, a seven-day retention doesn't help you.

Scheduling it

I use a systemd timer instead of cron, mainly because a failed run shows up in systemctl status and the journal without extra work.

# /etc/systemd/system/restic-backup.service
[Unit]
Description=Restic backup
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/restic-backup.sh

# /etc/systemd/system/restic-backup.timer
[Unit]
Description=Nightly restic backup

[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15min
Persistent=true

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode

Enable it with systemctl enable --now restic-backup.timer. Cron works just as well if that's what you already have. What matters is that something tells you when it fails. I add a final line to the script that pings a monitoring URL on success, so a missed backup raises an alert through silence. Dead man's switches are underrated.

Check the repository, then restore something

Two commands separate a backup from a hope. The first is restic check, which verifies the repository structure. The documentation on working with repositories describes --read-data-subset, which checks a portion of the pack files per run, for example --read-data-subset=1/5 on Monday, 2/5 on Tuesday and so on, so each night reads a fifth of the data and a full pass completes in five runs. That is a sane compromise when reading everything would cost bandwidth.

The second is a restore test, and I'd do it monthly:

restic restore latest --tag files --target /tmp/restore-test --include /etc/nginx
restic dump latest myapp.sql --tag postgres | head -n 20
Enter fullscreen mode Exit fullscreen mode

Restore into a scratch directory, open the files, load the dump into a throwaway database, and check a row count against production. Write down how long it took. That number is your real recovery time, and it's the one you'll want in your head when something breaks at night.

Do this tonight

Pick one server. Create a repository on storage that lives somewhere else, run restic init, and run the script above by hand with the file backup only. Then restore one directory to /tmp and compare it with the original using diff -r. Once that works, add the database line, then the timer. If you want a second pair of eyes on a setup like this for a client system, I take on infrastructure work, and you can find more about it on my portfolio. If your data lives in Postgres, my post on what stays plaintext under encryption at rest is a useful companion, because backups need the same care as the live database.


Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.

Top comments (0)