{"id":782,"date":"2026-10-14T05:00:45","date_gmt":"2026-10-14T05:00:45","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/restic-backup-small-vps-postgres-dump-retention-restore-test\/"},"modified":"2026-10-14T05:00:45","modified_gmt":"2026-10-14T05:00:45","slug":"restic-backup-small-vps-postgres-dump-retention-restore-test","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/restic-backup-small-vps-postgres-dump-retention-restore-test\/","title":{"rendered":"Restic Backup for a Small VPS: Script, Retention and Restore Test"},"content":{"rendered":"<p>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 <code>tar<\/code> job that has been filling a disk since 2023 and has never been opened.<\/p>\n<p>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&rsquo;d use. I&rsquo;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.<\/p>\n<p>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.<\/p>\n<h2 id=\"why-restic-suits-a-small-vps\">Why restic suits a small VPS<\/h2>\n<p>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 <a href=\"https:\/\/restic.readthedocs.io\/en\/stable\/030_preparing_a_new_repo.html\" rel=\"nofollow noopener\" target=\"_blank\">restic documentation on preparing a repository<\/a> lists the supported backends.<\/p>\n<p>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 <code>rm -rf<\/code> 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.<\/p>\n<p>Initialise once:<\/p>\n<pre><code class=\"language-bash\">export RESTIC_REPOSITORY=&quot;sftp:backup@storagebox.example.com:\/restic\/myserver&quot;\nexport RESTIC_PASSWORD_FILE=&quot;\/etc\/restic\/password&quot;\n\nrestic init\n<\/code><\/pre>\n<p>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&rsquo;t plan to.<\/p>\n<h2 id=\"the-backup-script\">The backup script<\/h2>\n<p>Here is the script I start from. It is plain Bash on purpose.<\/p>\n<pre><code class=\"language-bash\">#!\/usr\/bin\/env bash\nset -euo pipefail\n\nexport RESTIC_REPOSITORY=&quot;sftp:backup@storagebox.example.com:\/restic\/myserver&quot;\nexport RESTIC_PASSWORD_FILE=&quot;\/etc\/restic\/password&quot;\n\n# 1. Files: configs, uploads, compose projects\nrestic backup \\\n  --tag files \\\n  --one-file-system \\\n  --exclude-file \/etc\/restic\/excludes.txt \\\n  \/etc \/srv \/home\/deploy\n\n# 2. Database: dump straight into the repository, no temp file\nrestic backup \\\n  --tag postgres \\\n  --stdin-from-command \\\n  --stdin-filename myapp.sql \\\n  -- sudo -u postgres pg_dump --format=plain myapp\n\n# 3. Retention\nrestic forget \\\n  --tag files --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune\nrestic forget \\\n  --tag postgres --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune\n<\/code><\/pre>\n<p>A few choices in there are worth explaining.<\/p>\n<p>The <code>--one-file-system<\/code> flag. The <a href=\"https:\/\/restic.readthedocs.io\/en\/stable\/040_backup.html\" rel=\"nofollow noopener\" target=\"_blank\">restic backup documentation<\/a> says it stops restic from crossing filesystem boundaries and subvolumes. On a VPS that keeps it from wandering into mounted volumes, network shares or <code>\/proc<\/code>-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.<\/p>\n<p>The exclude file. Things like <code>node_modules<\/code>, build caches, <code>*.log<\/code>, and Docker&rsquo;s own storage directory should stay out. You back up your compose files and volumes, not <code>\/var\/lib\/docker\/overlay2<\/code>. The same docs page describes <code>--exclude-file<\/code> as a file listing items to skip, one pattern per line.<\/p>\n<p>The Postgres dump. The same documentation explains that <code>--stdin-from-command<\/code> makes restic run a command and store its output, and that you must put <code>--<\/code> before the command so restic doesn&rsquo;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&rsquo;d switch to <code>pg_dump --format=custom<\/code> and restore with <code>pg_restore<\/code>, which also lets you restore a single table.<\/p>\n<p>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 <code>forget<\/code>, and I use them for exactly that.<\/p>\n<h2 id=\"retention-without-surprises\">Retention without surprises<\/h2>\n<p>Retention is where I&rsquo;ve seen people shoot themselves. The <a href=\"https:\/\/restic.readthedocs.io\/en\/stable\/060_forget.html\" rel=\"nofollow noopener\" target=\"_blank\">restic forget documentation<\/a> explains the policy flags: <code>--keep-daily n<\/code> 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 <code>forget --prune<\/code> removes the data that was referenced only by the snapshots you dropped, which can take a while on a large repository.<\/p>\n<p>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 <code>restic snapshots<\/code> after any path change and look at what groups exist. Second, run <code>forget<\/code> with <code>--dry-run<\/code> the first time. Reading the list of what would be removed costs nothing.<\/p>\n<p>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&rsquo;d take to notice a problem. If a bug quietly corrupts data and you notice a month later, a seven-day retention doesn&rsquo;t help you.<\/p>\n<h2 id=\"scheduling-it\">Scheduling it<\/h2>\n<p>I use a systemd timer instead of cron, mainly because a failed run shows up in <code>systemctl status<\/code> and the journal without extra work.<\/p>\n<pre><code class=\"language-ini\"># \/etc\/systemd\/system\/restic-backup.service\n[Unit]\nDescription=Restic backup\nAfter=network-online.target\n\n[Service]\nType=oneshot\nExecStart=\/usr\/local\/bin\/restic-backup.sh\n\n# \/etc\/systemd\/system\/restic-backup.timer\n[Unit]\nDescription=Nightly restic backup\n\n[Timer]\nOnCalendar=*-*-* 03:15:00\nRandomizedDelaySec=15min\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n<\/code><\/pre>\n<p>Enable it with <code>systemctl enable --now restic-backup.timer<\/code>. Cron works just as well if that&rsquo;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&rsquo;s switches are underrated.<\/p>\n<h2 id=\"check-the-repository-then-restore-something\">Check the repository, then restore something<\/h2>\n<p>Two commands separate a backup from a hope. The first is <code>restic check<\/code>, which verifies the repository structure. The <a href=\"https:\/\/restic.readthedocs.io\/en\/stable\/045_working_with_repos.html\" rel=\"nofollow noopener\" target=\"_blank\">documentation on working with repositories<\/a> describes <code>--read-data-subset<\/code>, which checks a portion of the pack files per run, for example <code>--read-data-subset=1\/5<\/code> on Monday, <code>2\/5<\/code> 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.<\/p>\n<p>The second is a restore test, and I&rsquo;d do it monthly:<\/p>\n<pre><code class=\"language-bash\">restic restore latest --tag files --target \/tmp\/restore-test --include \/etc\/nginx\nrestic dump latest myapp.sql --tag postgres | head -n 20\n<\/code><\/pre>\n<p>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&rsquo;s the one you&rsquo;ll want in your head when something breaks at night.<\/p>\n<h2 id=\"do-this-tonight\">Do this tonight<\/h2>\n<p>Pick one server. Create a repository on storage that lives somewhere else, run <code>restic init<\/code>, and run the script above by hand with the file backup only. Then restore one directory to <code>\/tmp<\/code> and compare it with the original using <code>diff -r<\/code>. 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 <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>. If your data lives in Postgres, my post on <a href=\"https:\/\/abrarqasim.com\/blog\/postgres-encryption-at-rest-luks-vs-pg-vault-tde-what-stays-plaintext\/\" rel=\"noopener\">what stays plaintext under encryption at rest<\/a> is a useful companion, because backups need the same care as the live database.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A restic backup setup for a small VPS: encrypted snapshots off-server, Postgres dumps piped straight in, sane retention, a systemd timer, and a restore test.<\/p>\n","protected":false},"author":2,"featured_media":781,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"A restic backup setup for a small VPS: encrypted snapshots off-server, Postgres dumps piped straight in, sane retention, a systemd timer, and a restore test.","rank_math_focus_keyword":"restic backup","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302],"tags":[861,80,177,860,8],"class_list":["post-782","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","tag-backup","tag-devops","tag-postgres","tag-restic","tag-self-hosting"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/782","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=782"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/782\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/781"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=782"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=782"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=782"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}