Back to Article List

Automated VPS backups with restic or Borg off-site

Automated VPS backups with restic or Borg off-site

Updated and tested 10 September 2026

The snapshot button in the panel is not a backup. It's a copy of the disk image on the same storage, in the same datacenter and under the same account, and it disappears with the VM if the account is closed, the host's storage fails in a way the provider's redundancy doesn't cover or someone with your panel password decides to delete it. It's a fine way to roll back a bad upgrade, which is what it's for. A backup is a copy that survives every failure the original can have, and on a VPS that means encrypted data on a different machine, ideally at a different provider, written by a job the VM runs itself. The 3-2-1 version of that sentence is three copies on two kinds of storage with one off-site, and for a single VPS I settle for two: the live disk and one encrypted repository somewhere else.

Commands assume Ubuntu 24.04 LTS.

Provider snapshots vs your own backups

Keep both. A scheduled snapshot at the panel level costs nothing to set up and restores a whole VM in minutes, which no file-level tool matches, and the provider's guide to creating scheduled VPS backups is a five-minute job. What the snapshot can't do is give you Tuesday's version of one config file without restoring all of Tuesday, prove that the data is readable without a full restore or live outside the provider's control. Those three things are the job of restic or Borg, and the rest of this article is about them.

restic or Borg

Both encrypt on the client, deduplicate at the chunk level, keep a history of snapshots and prune old ones by a retention policy. Both are mature and neither has lost me data. The difference that decides it is the target. restic talks to SFTP, S3 and anything rclone can reach directly, in one static Go binary with no dependency on the far end, and it's the one I run on a VPS that backs up to object storage. Borg needs Borg installed on the remote side (it runs borg serve over SSH) and works best against a Linux box you control, where its dedup is tighter and its compact step reclaims space more predictably than restic's prune. On a hard-disk storage VPS reached over SSH, I'd pick Borg. On a bucket, restic, and I don't think the choice is worth agonising over past that.

Version matters for restic on 24.04. The apt package is 0.16.4, the current release is 0.19.1 (5 July 2026), and 0.17.0 in July 2024 added --stdin-from-command, the option that lets restic run pg_dump itself and refuse to save a snapshot if the dump fails. The Ubuntu build also has the self-update command removed. Two years of changes and one option I use on every box, so the release binary it is.

restic backup --stdin-from-command with a failing command: unable to save snapshot, exit code 1

When the dump command fails, restic refuses to save a snapshot of the partial output.

Install restic on Ubuntu 24.04

sudo apt install bzip2
curl -sSLO https://github.com/restic/restic/releases/download/v0.19.1/restic_0.19.1_linux_amd64.bz2
curl -sSLO https://github.com/restic/restic/releases/download/v0.19.1/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
bunzip2 restic_0.19.1_linux_amd64.bz2
sudo install -m 755 restic_0.19.1_linux_amd64 /usr/local/bin/restic
restic version

restic version: restic 0.19.1 compiled with go1.26.4 on linux/amd64

The release binary from GitHub, newer than the 0.16.4 in Ubuntu's archive.

The first line is there because bunzip2 comes from the bzip2 package, which not every 24.04 server image includes; without it bunzip2 is command not found and the install line has nothing to install. sha256sum should print restic_0.19.1_linux_amd64.bz2: OK before you unpack anything, and the SHA256SUMS file itself is signed with the restic release key if the box matters enough to check that too. Later upgrades are sudo restic self-update, which the release binary supports and the apt one doesn't. If you'd rather stay on apt, sudo apt install restic works, everything below runs on 0.16.4 except the --stdin-from-command line, and you'll want to know that when you copy it.

Initialise the repository

The environment file

restic reads its settings from environment variables, and a root-only file is the way to hand them to a systemd unit:

sudo mkdir -p /etc/restic
sudo install -m 600 /dev/null /etc/restic/env
sudo install -m 600 /dev/null /etc/restic/password
sudo tee /etc/restic/env >/dev/null <<'EOF'
RESTIC_REPOSITORY=sftp:restic@STORAGE_HOST:/srv/restic/web01
RESTIC_PASSWORD_FILE=/etc/restic/password
EOF
openssl rand -base64 32 | sudo tee /etc/restic/password >/dev/null

The two install lines create both files empty and root-only before anything is written to them. tee on its own creates them world-readable, and with a chmod 600 afterwards the password is still readable by every user for the moment in between.

Copy the password somewhere that is not this VM. A password manager, a printed sheet in a drawer, anything. Without it the repository is a pile of random bytes and there's no reset, which is what encryption on the client side means. For an S3-compatible target the env file gains AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY and the repository line becomes s3:https://S3_ENDPOINT/BUCKET/web01, the form documented in restic's page on preparing a new repository. That page also covers the rclone backend, rclone:REMOTE:bucket/web01, which is how restic reaches Backblaze B2, Google Drive and the rest of rclone's list; configure the remote with rclone config as root first, because the timer runs as root and rclone keeps its config per user.

SFTP target

The storage host needs one account and one directory before the VPS can write to it: a user with a login shell, called restic here, and /srv/restic owned by that user. restic creates web01 inside it on init. Pick a name other than backup if the storage host runs Ubuntu, because that name already belongs to a system account with no login shell and adduser backup fails with The user `backup' already exists. The shell matters as well, since ssh starts the SFTP server through it.

The SFTP backend shells out to ssh, so the root user on the VPS needs a key that the backup host accepts and the host key already in /root/.ssh/known_hosts. Do it once by hand, and the interactive host-key prompt happens now and not inside a hung service overnight:

sudo ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""
sudo cat /root/.ssh/id_ed25519.pub   # append to restic@STORAGE_HOST:~/.ssh/authorized_keys
sudo ssh restic@STORAGE_HOST true
sudo bash -c 'set -a; . /etc/restic/env; restic init'

created restic repository plus a short ID is the success line. The set -a trick exports every variable in the env file for the one command; you'll use it again for every manual restic call in this article, so an alias in root's shell is worth the ten seconds.

The first backup

Excludes and --one-file-system

Write the exclude list before the first run, because the first run is the slow one and re-uploading a package cache you forgot to skip is wasted time:

sudo tee /etc/restic/excludes.txt >/dev/null <<'EOF'
/var/cache
/var/tmp
/var/lib/apt/lists
/home/*/.cache
*.tmp
EOF

Docker needs no line here, because nothing under /var/lib is on the source list below. Image layers stay out, which is what you want: they're rebuildable from the registry, change on every pull and wreck dedup (with Docker 29's containerd image store they sit under /var/lib/containerd anyway, so an exclude for /var/lib/docker/overlay2 matches nothing). Named volumes stay out too, which is less obvious: data in /var/lib/docker/volumes is backed up only if you add that path as a source. Where bind-mounted data directories live is a layout decision that Docker on a VPS goes into, and whichever path you chose has to be on the source list below.

--one-file-system keeps restic on the filesystem the source path lives on, which on a VPS means /proc, /sys and a mounted second disk are all skipped without listing them; if the second disk holds data you want, name its mount point as a separate source path. --exclude-caches honours the CACHEDIR.TAG convention that pip, cargo and a few others already use.

Database dumps first

Backing up the files under a running Postgres or MySQL data directory gives you a copy that may not start. Dump first, then back up the dump. A small script the service runs before restic:

sudo tee /usr/local/bin/restic-dumps.sh >/dev/null <<'EOF'
#!/bin/bash
set -euo pipefail
umask 077
mkdir -p /var/backups/db
sudo -u postgres pg_dumpall --clean > /var/backups/db/postgres-all.sql
# mysqldump --single-transaction --all-databases > /var/backups/db/mysql-all.sql
EOF
sudo chmod 755 /usr/local/bin/restic-dumps.sh

umask 077 keeps the dump readable by root only. Without it postgres-all.sql comes out world-readable, and a pg_dumpall file holds every database plus the password hash of every role that has one.

Plain SQL, uncompressed. A compressed dump changes almost every byte between runs and restic can't dedupe it, while plain text of a database that changed 2% since yesterday uploads roughly 2%. Leave the compression to restic, which applies it per chunk on repositories created with 0.14 or later. If you'd rather not keep a dump on local disk, the direct form documented under backing up with restic is restic backup --stdin-from-command --stdin-filename postgres-all.sql -- sudo -u postgres pg_dumpall --clean, and with 0.19 a non-zero exit from pg_dumpall aborts the snapshot instead of saving a truncated one, which is the behaviour the older --stdin pipe form can't give you.

Then the first run, by hand, so you watch it:

sudo /usr/local/bin/restic-dumps.sh
sudo bash -c 'set -a; . /etc/restic/env; restic backup --one-file-system --exclude-caches \
  --exclude-file=/etc/restic/excludes.txt /etc /home /var/backups/db /var/www /srv'

List only paths that exist on your server. A missing one, /var/www on a box without a web server for example, prints /var/www does not exist, skipping, the snapshot is saved anyway and restic exits with code 3. By hand that's a warning to read; in the systemd unit below it marks the run as failed, and the forget and prune after it never run. The unit repeats the same source list, so trim it there too.

Expect the initial upload of a 20 GB web box to take an hour or two, bounded by the outbound bandwidth to the target, and every run after that to finish in a few minutes because only changed chunks travel. The summary line prints files new, changed and unmodified, plus data added to the repository, and the second run's "added" figure is the number that tells you the excludes are right.

A systemd service and timer

A timer and not a cron line, because the log lands in the journal with the unit's name on it, a failed run shows as a failed unit you can alert on, and Persistent=true runs a missed job after a reboot instead of skipping the night.

/etc/systemd/system/restic-backup.service:

[Unit]
Description=restic backup to off-site repository
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
CacheDirectory=restic
Environment=RESTIC_CACHE_DIR=/var/cache/restic
ExecStartPre=/usr/local/bin/restic-dumps.sh
ExecStart=/usr/local/bin/restic backup --one-file-system --exclude-caches --exclude-file=/etc/restic/excludes.txt /etc /home /var/backups/db /var/www /srv
ExecStartPost=/usr/local/bin/restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
Nice=10
IOSchedulingClass=idle

journalctl for restic-backup.service: snapshot saved, then the keep 7 daily, 4 weekly, 6 monthly policy applied

One run of the service: restic saves the snapshot, then forget applies the retention policy.

The CacheDirectory and RESTIC_CACHE_DIR lines are what make this run under systemd. A system service has no $HOME, and without a cache path restic stops with unable to locate cache directory: neither $XDG_CACHE_HOME nor $HOME are defined before it reads a single file. /var/cache is already in the excludes, so the cache stays out of its own backups. The check unit below doesn't need them, because check works from a temporary cache.

/etc/systemd/system/restic-backup.timer:

[Unit]
Description=Run restic backup nightly

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

[Install]
WantedBy=timers.target

Enable the timer, not the service, then confirm the next elapse time:

sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
systemctl list-timers restic-backup.timer
sudo systemctl start restic-backup.service   # one real run through the unit
journalctl -u restic-backup.service -n 30

systemctl list-timers restic-backup.timer: next run Tue 03:29 UTC, activates restic-backup.service

The nightly timer armed, with the random delay already applied to the next run.

A failed ExecStartPre stops the unit before restic runs, so a broken dump never produces a snapshot without the database in it. Nice and the idle I/O class keep a full backup from stealing the disk from the web server at 03:15, at the cost of a slower backup on a busy box, which is the right trade for a nightly job.

Retention, prune and the monthly check

forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 keeps the last seven daily snapshots, the last four weekly ones and the last six monthly ones, and everything else is dropped from the snapshot list. Without --prune the data those snapshots referenced stays in the repository; with it, restic rewrites pack files to remove unreferenced chunks, which on a large repository is the slowest thing restic does and the reason some people move the forget step to a weekly timer. On a repository under 100 GB, nightly with --prune is fine.

/etc/systemd/system/restic-check.service:

[Unit]
Description=restic repository check

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
ExecStart=/usr/local/bin/restic check --read-data-subset=10%%
Nice=10
IOSchedulingClass=idle

/etc/systemd/system/restic-check.timer:

[Unit]
Description=Run restic check monthly

[Timer]
OnCalendar=*-*-01 04:30:00
Persistent=true

[Install]
WantedBy=timers.target

The doubled %% is how a literal percent sign is written in a unit file. check alone verifies the repository's structure; --read-data-subset=10% also downloads a tenth of the pack files and verifies their contents, so over ten months every byte has been read back once, without paying object-storage egress for the whole repository monthly. On an SFTP target where the transfer is free, --read-data and be done.

restic-check.service journal: read 10.0% of packfiles, no errors were found

The monthly check reads a tenth of the data and reports a clean repository.

A short digression on why the check matters more than it looks. restic's chunker splits files at content-defined boundaries using a secret polynomial, so an insert near the start of a file shifts only one chunk, and a 2025 paper by Alexeev, Percival and Zhang showed that an attacker who can observe chunk sizes for a known file can recover that polynomial. The 0.18.0 release in March 2025 answered it by randomising how chunks are packed into pack files, so the sizes can't be observed. None of this affects your data day to day, and it's a reminder that a backup tool is software with a changelog, which is one more reason to run the current binary.

Restore and the restore drill

The commands are short. Finding out what's in a snapshot:

sudo bash -c 'set -a; . /etc/restic/env; restic snapshots'
sudo bash -c 'set -a; . /etc/restic/env; restic ls latest /etc/nginx'

Restoring a path from the latest snapshot into a scratch directory, which is where every restore starts:

sudo bash -c 'set -a; . /etc/restic/env; restic restore latest --target /tmp/restore --include /etc/nginx'

restic restore latest --include /etc/hostname: Summary Restored 2 / 1 files/dirs (8 B / 8 B)

Restoring a single file (/etc/hostname here) into /tmp/restore.

Then diff -r /etc/nginx /tmp/restore/etc/nginx shows what changed, and you copy back only what you meant to. A whole-box restore onto a fresh VM is restic restore latest --target / after installing the same packages, followed by sudo cat /var/backups/db/postgres-all.sql | sudo -u postgres psql for the database, as the postgres user because root has no database role. For browsing without restoring, restic mount /mnt/restic exposes every snapshot as a directory tree; it needs the fuse3 package on 24.04.

The drill is the part that separates a backup from a hope. Once a month, on the first Monday, I restore one file and the database dump into /tmp/restore, load the dump into a throwaway Postgres, look at the dump's timestamp and delete both. Fifteen minutes. The two failures it has caught for me were a password file that had been rotated on the VPS but not in the password manager and an excludes pattern that had matched /var/www after a directory rename. Both would have surfaced on the day I needed the backup, which is the one day you don't want surprises.

Throwaway means a separate server, never the live one. A pg_dumpall --clean file carries DROP DATABASE, CREATE DATABASE and \connect for every database in it, so loading it with psql on the production box replaces your real databases with the dump's copies, whichever database you pointed psql at. A container with the same Postgres major version does the job:

sudo docker run -d --name pgdrill -e POSTGRES_PASSWORD=drill postgres:16
sleep 10
sudo cat /tmp/restore/var/backups/db/postgres-all.sql | sudo docker exec -i pgdrill psql -U postgres
sudo docker exec pgdrill psql -U postgres -l
sudo docker exec pgdrill psql -U postgres -d YOUR_DB -c '\dt'
sudo docker rm -f pgdrill

The two errors about the postgres role (it can't be dropped, it already exists) are expected. A database missing from the list or a table missing from it is what the drill is there to catch. Without Docker on the box, any spare VM with the same Postgres version works, or dump each database separately with pg_dump and load that into a scratch database on the live server.

Monitoring the timer

systemctl list-timers shows the last and next run, and systemctl is-failed restic-backup.service exits non-zero after a failed run, which is enough for a Monit check or a cron line that mails you. Better is a dead-man's switch: the service's last line pings a URL, and a monitor that expects that ping every 24 hours alerts when it stops arriving, which catches the box being off, the timer being disabled and the network being down, none of which a failure alert from the box itself can report. The push monitor type in Uptime Kuma does this, and wiring it up is in how to monitor a VPS. Add ExecStartPost=/usr/bin/curl -fsS -m 10 https://MONITOR_HOST/api/push/TOKEN as a second post line after the forget, and it only fires if everything before it succeeded.

Make room for every file you'll ever keep

Keep your backups and media libraries on reliable storage with unmetered bandwidth and a cost that stays predictable as your archive grows.
Betalingscyclus

VPS.H1

$6.99 Save  14 %
$5.99 Maandelijks
  • 1 vCPU AMD EPYC
  • 2 GB RAM-geheugen
  • ✔HDD opslag
  • ✔Ongelimiteerde bandbreedte
  • ✔ IPv4 & IPv6 inbegrepen IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk of Nederland.
  • ✔Firewall-beheer
  • ✔Serverbewaking gratis
  • ✔KVM-virtualisatie

VPS.H3

$24.99 Save  20 %
$19.99 Maandelijks
  • 4 vCPU AMD EPYC
  • 8 GB RAM-geheugen
  • ✔HDD opslag
  • ✔Ongelimiteerde bandbreedte
  • ✔ IPv4 & IPv6 inbegrepen IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk of Nederland.
  • ✔Firewall-beheer
  • ✔Serverbewaking gratis
  • ✔KVM-virtualisatie

VPS.H4

$36.49 Save  18 %
$29.99 Maandelijks
  • 6 vCPU AMD EPYC
  • 12 GB RAM-geheugen
  • ✔HDD opslag
  • ✔Ongelimiteerde bandbreedte
  • ✔ IPv4 & IPv6 inbegrepen IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk of Nederland.
  • ✔Firewall-beheer
  • ✔Serverbewaking gratis
  • ✔KVM-virtualisatie

*VAT excluded.

INCLUDED WITH EVERY PLAN

✓ No setup fees ✓1 Gbps network
✓ Free server monitoring ✓ Firewall management ✓24/7 support ✓ KVM virtualization

The Borg equivalent

Same shape, different commands. The apt package on 24.04 is borgbackup 1.2.8; the current release is 1.4.5 (July 2026) and 2.0 is still in beta as I write this, so 1.x is what you run and the apt version is acceptable if both ends match. Borg has to be installed on the target too, which is the reason it suits a storage VPS or a friend's server over SSH and doesn't suit a bucket. On the storage host that means apt install borgbackup and an account prepared the same way as the restic one, called borg here, with a login shell and the VPS root key in its authorized_keys. Run sudo ssh borg@STORAGE_HOST true once from the VPS if it's a new host, for the host-key prompt.

sudo apt install borgbackup
sudo mkdir -p /etc/borg
sudo install -m 600 /dev/null /etc/borg/env
sudo tee /etc/borg/env >/dev/null <<'EOF'
BORG_REPO=ssh://borg@STORAGE_HOST/./borg/web01
BORG_PASSPHRASE=CHANGE_ME_LONG_RANDOM
EOF
sudo bash -c 'set -a; . /etc/borg/env; borg init --encryption=repokey --make-parent-dirs'
sudo bash -c 'set -a; . /etc/borg/env; borg key export :: /root/borg-key-web01.txt'

ls -l /etc/borg/env shows -rw------- root root; borgbackup 1.2.8 from Ubuntu

The Borg passphrase file readable by root only.

The package doesn't create /etc/borg, hence the mkdir. --make-parent-dirs lets init create ~/borg on the storage side; on a fresh account it stops without it, with The parent path of the repo directory ... does not exist.

repokey stores the encrypted key inside the repository, unlocked by the passphrase; keyfile keeps it only on the client. The Borg quickstart is blunt that the data is "totally inaccessible without the key and the key passphrase", so the exported key goes to the same off-VM place as the restic password. The nightly run, which drops into the same systemd service shape as above:

borg create --stats --compression zstd,3 --exclude-caches --one-file-system \
  --exclude-from /etc/restic/excludes.txt \
  ::'{hostname}-{now:%Y-%m-%d-%H%M}' /etc /home /var/backups/db /var/www /srv
borg prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
borg compact

In a unit file every % in the archive name is written %%: ::'{hostname}-{now:%%Y-%%m-%%d-%%H%%M}'. It's the same rule as the 10%% in the check unit. With single signs systemd expands %Y, %m and %d as its own specifiers and borg stops with Invalid location format. systemd-analyze verify doesn't flag the line, so the first sign is a failed run. The hour and minute in the name keep a manual test run and the nightly one on the same day apart; with the date alone the second stops with Archive ... already exists. The rule about sources holds here too: Borg warns about a missing path and exits 1, which fails the unit the same way restic's exit 3 does.

Borg 1.2 split prune into two steps: prune removes archives from the index and compact is what frees the space on disk, so a prune without a compact afterwards leaves the repository the same size. zstd,3 is the compression I use on a VPS with spare CPU; lz4 if it hasn't. Restores are borg extract ::web01-2026-09-07-0315 etc/nginx from inside the target directory (borg list gives the exact names, which start with the machine's hostname), or borg mount for browsing. One Borg feature with no restic equivalent worth the mention: an authorized_keys entry on the storage side of command="borg serve --append-only --restrict-to-path /srv/borg/web01" makes the VPS unable to delete or prune its own history, so a compromised VPS can't destroy its backups. Pruning then happens from a separate, trusted key.

Where the repository lives

An HDD-backed storage VPS at another provider over SFTP for Borg, an S3-compatible bucket for restic, and in both cases somewhere with a different failure domain from the machine being backed up. The trade-offs between the two, including what a restore costs in egress from a bucket, are worked through in storage VPS vs object storage vs NAS; the short version is that the storage VPS wins on drill cost and the bucket wins on not being a machine you have to patch. For getting a Borg repository into a bucket anyway, rclone sync /srv/borg/web01 remote:bucket/web01 after each compact works, with the caveat that a sync of a half-written repository is a half-written copy, so run it from the same unit after Borg exits.

What I don't have good numbers for is how restic's deduplication holds up against a busy Postgres over a year. A plain-SQL pg_dumpall whose rows mostly stay put from one night to the next adds only the chunks around the changed rows, a small slice of the dump, but that depends entirely on the application's update pattern, and a table with a rewritten timestamp column on every row behaves much worse. Measure your own second and third runs before deciding the retention policy is affordable.

Questions?

Can I back up two VPSes into the same restic repository?

Yes, restic is built for it: each snapshot records the hostname, forget groups snapshots by host and paths before it applies a policy, and files the servers have in common are stored once. The catch is in restic's own threat model, which says that anyone with the password of a shared repository can read every host's backups, and changing the password later doesn't shut out someone who already had it. Keep separate repositories when the servers belong to different people or one of them is far more exposed. For a manual forget that should touch one server only, add --host web01.

About the author

Diego Cabrera Domínguez

My job is systems administration. I write in the evenings or at the weekend, depending on the week. I started because I kept notes for everything I configured: mail servers, backups, monitoring, Docker, strange permissions problems, things I knew I would forget six months later. Some of those notes became articles. If I have not tested a procedure myself, I do not feel comfortable writing instructions for it!

I am from Granada and still live there. I studied computer systems administration and went directly into IT. I play football with friends once a week when enough people answer the group chat, and I repair old electronics, mostly motherboards and game consoles. I keep several boxes of parts that my mother believes should have been thrown away a long time ago. That's about it. I'm not very good at biographies.

Never run out of space again

Keep backups, archives and media on reliable storage with unmetered bandwidth and a cost per gigabyte that stays low as the years add up. Your first VPS comes with a 30-day money-back guarantee.