Automatic Offsite Backups with Restic or BorgBackup
There are two kinds of sysadmins: those who have already lost data, and those who are about to. The question is not whether a failing disk, a botched update, an rm -rf in the wrong directory or a ransomware attack will hit you, but when, and what you will have at hand when it does.
In under an hour, this guide sets up an automatic, encrypted, incremental and offsite backup of your VPS with one of the two best open-source tools in the field: Restic or BorgBackup. Both are excellent; we compare them so you can make an informed choice, then walk through each setup end to end, all the way to the restore test, without which a backup is nothing more than a hope.
The 3-2-1 Rule and What YorkHost Already Does for You
The reference rule: 3 copies of your data, on 2 different media, 1 of them offsite.
Your YorkHost VPS already includes daily backups kept on a 7-day rolling window, restorable in one click from the client area. On the VPS Ultimate range, they are even stored in a different datacenter. This is an excellent first line of defense: if the VPS is corrupted after a kernel update, you roll back to the previous day in a few minutes.
But these snapshots have three limitations, common to every hosting provider:
- Granularity: it is the whole disk or nothing. Recovering a single deleted file means restoring the entire VPS somewhere else.
- Retention: 7 days. Silent corruption discovered three weeks later is no longer recoverable.
- Application consistency: a disk snapshot taken in the middle of a MariaDB or PostgreSQL write is not guaranteed to be consistent. You need a clean dump.
The application-level backup in this guide covers exactly these three gaps. The two mechanisms complement each other: the snapshot for system-wide disasters, Restic or Borg for your data.
Restic or Borg?
| Restic | BorgBackup | |
|---|---|---|
| Language, binary | Go, a single static binary | Python + C, package to install |
| Destinations | SFTP, S3, Backblaze B2, Azure, Google Cloud, rest-server, local, rclone (anything) | SSH (with Borg installed on the server side), local |
| Encryption | Always on, AES-256 | Optional, AES-256 or ChaCha20, repokey/keyfile |
| Deduplication | Yes, variable-size blocks | Yes, variable-size blocks |
| Compression | Yes (auto, zstd) since v0.14 | Yes, lz4/zstd/zlib/lzma, your choice |
| Speed | Good, slightly heavier on RAM and requests | Excellent over SSH, very lightweight |
| Ransomware protection | Depends on the backend (append-only with rest-server, S3 object lock) | Native --append-only mode on the SSH server side |
| Concurrent access | Several clients on the same repository | One client at a time per repository |
| Mounting the repository | restic mount | borg mount |
In short:
- Choose Restic if you want to back up to object storage (S3, B2) or to several kinds of destinations, or if several servers need to share a single repository.
- Choose Borg if your destination is a server reachable over SSH (such as a VPS Storage) and you want the fastest, most resource-efficient solution, with the simplest append-only mode to set up.
Both are mature (over ten years for Borg), actively maintained, and used in production by thousands of organizations. You cannot make a wrong choice.
Where to Send the Backups?
"Offsite" means: on a machine that shares neither the disk, nor the hypervisor, nor ideally the datacenter of your VPS. Three common options:
| Destination | Advantages | Things to watch |
|---|---|---|
| YorkHost VPS Storage (500 GB or 1 TB SSD) | Native SSH/SFTP access, Borg or Restic with no middleman, fast network, Anti-DDoS included, flat rate with no per-request billing | Same provider: perfect against losing the VPS, to be complemented by a second repository elsewhere if you want to survive the loss of an entire provider |
| S3-compatible object storage (Backblaze B2, Scaleway, Wasabi...) | Very cheap per TB, durable, object lock available | Restic only, request and egress costs to keep an eye on |
| A machine at home (NAS, Raspberry Pi) | You physically hold the data | Slow home connection, dynamic IP, a machine to maintain |
This guide uses a YorkHost VPS Storage as the primary destination, with the fictional IP 203.0.113.200. The commands are identical for any SSH server.
Step 1: Prepare the Storage Server
On the VPS Storage, create a dedicated backup user with no interactive shell, and a directory for the repositories:
sudo adduser --disabled-password --gecos "" backup
sudo mkdir -p /srv/backups
sudo chown backup:backup /srv/backups
sudo chmod 700 /srv/backups
On the VPS to back up, generate a dedicated SSH key (without a passphrase, since it will be used by a timer with no human intervention):
sudo ssh-keygen -t ed25519 -f /root/.ssh/backup_ed25519 -N "" -C "backup@$(hostname)"
sudo cat /root/.ssh/backup_ed25519.pub
On the VPS Storage, authorize this key for the backup user:
sudo mkdir -p /home/backup/.ssh
sudo nano /home/backup/.ssh/authorized_keys
# paste the public key
sudo chown -R backup:backup /home/backup/.ssh
sudo chmod 700 /home/backup/.ssh && sudo chmod 600 /home/backup/.ssh/authorized_keys
Test from the source VPS:
sudo ssh -i /root/.ssh/backup_ed25519 backup@203.0.113.200 "echo OK"
To simplify every command that follows, declare the host in /root/.ssh/config:
Host storage
HostName 203.0.113.200
User backup
IdentityFile /root/.ssh/backup_ed25519
IdentitiesOnly yes
The VPS Storage holds a copy of all your data; it deserves the same care as the source: key-only SSH, UFW allowing port 22 only from the IPs of your source VPS, automatic updates.
Step 2: Decide What to Back Up
Backing up the whole disk is tempting but inefficient: the system can be reinstalled in ten minutes; it is your data and configuration that are irreplaceable. A typical list:
| What to back up | Why |
|---|---|
/etc | All system configuration (Nginx, SSH, cron, firewall...) |
/home and /root | User files, scripts, keys |
/var/www | Websites and web applications |
/opt | Manually installed applications, Docker stacks |
/var/lib/docker/volumes | Container data (as long as it does not contain a running database) |
/var/backups/db | The database dumps produced just before (see below) |
To exclude: /proc, /sys, /dev, /run, /tmp, caches (/var/cache, ~/.cache, node_modules), and the raw database data files (/var/lib/mysql, /var/lib/postgresql), which would be inconsistent.
Databases: a Dump Before Every Backup
Create a script that cleanly exports each database; it will be called at the start of every backup:
sudo mkdir -p /var/backups/db
sudo nano /usr/local/bin/dump-databases.sh
#!/bin/bash
set -euo pipefail
DEST=/var/backups/db
mkdir -p "$DEST"
# MariaDB / MySQL (if installed): the Unix root account connects without a password
if command -v mariadb-dump >/dev/null 2>&1; then
mariadb-dump --all-databases --single-transaction --routines --events \
| gzip -6 > "$DEST/mariadb-all.sql.gz"
elif command -v mysqldump >/dev/null 2>&1; then
mysqldump --all-databases --single-transaction --routines --events \
| gzip -6 > "$DEST/mysql-all.sql.gz"
fi
# PostgreSQL (if installed)
if command -v pg_dumpall >/dev/null 2>&1; then
sudo -u postgres pg_dumpall | gzip -6 > "$DEST/postgresql-all.sql.gz"
fi
chmod 600 "$DEST"/*.gz
sudo chmod +x /usr/local/bin/dump-databases.sh
sudo /usr/local/bin/dump-databases.sh && ls -lh /var/backups/db
For PostgreSQL in production, prefer one dump per database in custom format as described in Install production-ready PostgreSQL, which is more flexible to restore.
Option A: Restic
Installation
Debian and Ubuntu ship Restic, but often in an outdated version. The official binary is a single file:
sudo apt install -y restic
restic version
If the packaged version is too old (check github.com/restic/restic/releases), install the official binary over it:
RESTIC_VER=0.18.1 # replace with the latest stable version
wget -q "https://github.com/restic/restic/releases/download/v${RESTIC_VER}/restic_${RESTIC_VER}_linux_amd64.bz2"
bunzip2 "restic_${RESTIC_VER}_linux_amd64.bz2"
sudo install -m 755 "restic_${RESTIC_VER}_linux_amd64" /usr/local/bin/restic
restic version
Initialize the Repository
Restic always encrypts. The repository password is the only thing that lets you read your backups: generate a strong one, store it in a protected file and in your password manager.
sudo mkdir -p /etc/restic
openssl rand -base64 32 | sudo tee /etc/restic/password > /dev/null
sudo chmod 600 /etc/restic/password
Create an environment file so you do not have to repeat the parameters:
sudo nano /etc/restic/env
export RESTIC_REPOSITORY="sftp:storage:/srv/backups/$(hostname)-restic"
export RESTIC_PASSWORD_FILE="/etc/restic/password"
export RESTIC_COMPRESSION="auto"
sudo chmod 600 /etc/restic/env
source /etc/restic/env
sudo -E restic init
For an S3 destination instead, the repository is written as s3:s3.eu-west-3.amazonaws.com/my-bucket/restic and you add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to the environment file. Nothing else changes.
Exclusions
sudo nano /etc/restic/excludes.txt
/proc
/sys
/dev
/run
/tmp
/var/tmp
/var/cache
/var/lib/mysql
/var/lib/postgresql
/var/lib/docker/overlay2
/var/lib/docker/image
**/node_modules
**/.cache
**/*.log
/root/.cache
/home/*/.cache
The Backup Script
sudo nano /usr/local/bin/restic-backup.sh
#!/bin/bash
set -euo pipefail
source /etc/restic/env
echo "== Database dumps"
/usr/local/bin/dump-databases.sh
echo "== Backup"
restic backup \
/etc /root /home /var/www /opt /var/backups/db /var/lib/docker/volumes \
--exclude-file=/etc/restic/excludes.txt \
--exclude-caches \
--one-file-system \
--tag auto
echo "== Retention"
restic forget \
--keep-daily 14 \
--keep-weekly 8 \
--keep-monthly 12 \
--keep-yearly 2 \
--prune
echo "== Quick check"
restic check --read-data-subset=5%
echo "Backup finished: $(date)"
sudo chmod 700 /usr/local/bin/restic-backup.sh
sudo /usr/local/bin/restic-backup.sh
The first run sends everything; subsequent runs only transfer new or modified blocks, usually a few MB per day. --read-data-subset=5% re-reads 5% of the actual data on each pass: over twenty days, the entire repository gets verified without overloading the network.
Browse and Restore
source /etc/restic/env
# List snapshots
restic snapshots
# See what a snapshot contains
restic ls latest /var/www
# Restore a specific file or directory into /tmp/restore
restic restore latest --target /tmp/restore --include /var/www/monsite.fr/wp-config.php
# Restore an entire snapshot to its original location (careful: overwrites)
restic restore a1b2c3d4 --target /
# Browse the repository as a filesystem
mkdir -p /mnt/restic && restic mount /mnt/restic
# then in another terminal: ls /mnt/restic/snapshots/latest/
restic mount is the most pleasant feature for day-to-day use: you browse each snapshot like a folder and copy whatever you need.
Ransomware Protection with rest-server
If an attacker gets root on your VPS, they also get the SSH key and the repository password, and can therefore delete the backups. To guard against this, the destination must refuse deletions. With a plain SSH server, Restic cannot enforce that; the solution is rest-server, Restic's official HTTP server, run in --append-only mode on the VPS Storage:
# On the VPS Storage
wget https://github.com/restic/rest-server/releases/latest/download/rest-server_linux_amd64.tar.gz
tar -xzf rest-server_linux_amd64.tar.gz && sudo install rest-server*/rest-server /usr/local/bin/
sudo useradd -r -s /usr/sbin/nologin restic
sudo mkdir -p /srv/restic && sudo chown restic:restic /srv/restic
# /etc/systemd/system/rest-server.service
[Unit]
Description=Restic REST Server
After=network.target
[Service]
User=restic
ExecStart=/usr/local/bin/rest-server --path /srv/restic --append-only --private-repos --htpasswd-file /srv/restic/.htpasswd --listen 127.0.0.1:8000
Restart=always
[Install]
WantedBy=multi-user.target
Create the credentials with htpasswd -B -c /srv/restic/.htpasswd vps1, expose port 8000 behind an HTTPS Nginx reverse proxy restricted to the IPs of your VPS, then use RESTIC_REPOSITORY="rest:https://vps1:motdepasse@backup.example.com/vps1". In append-only mode, restic forget --prune must be run from the storage server (with restic -r /srv/restic/vps1), never from the client.
Option B: BorgBackup
Installation
Borg must be installed on both machines: the source VPS and the VPS Storage (the server runs borg serve on every connection).
# On both machines
sudo apt install -y borgbackup
borg --version
Debian 12 ships Borg 1.2, Debian 13 ships Borg 1.4. Both machines need compatible versions (same major version).
Initialize the Repository
sudo mkdir -p /etc/borg
openssl rand -base64 32 | sudo tee /etc/borg/passphrase > /dev/null
sudo chmod 600 /etc/borg/passphrase
sudo nano /etc/borg/env
export BORG_REPO="ssh://storage/srv/backups/$(hostname)-borg"
export BORG_PASSCOMMAND="cat /etc/borg/passphrase"
export BORG_RSH="ssh -i /root/.ssh/backup_ed25519 -o IdentitiesOnly=yes"
sudo chmod 600 /etc/borg/env
source /etc/borg/env
sudo -E borg init --encryption=repokey-blake2
The repokey mode stores the encryption key (itself protected by the passphrase) inside the repository. Export it and keep it somewhere else: without it, even with the passphrase, the repository is unreadable.
sudo -E borg key export "$BORG_REPO" /root/borg-key-$(hostname).txt
# Copy this file into your password manager, then delete it from the VPS
The Backup Script
sudo nano /usr/local/bin/borg-backup.sh
#!/bin/bash
set -euo pipefail
source /etc/borg/env
echo "== Database dumps"
/usr/local/bin/dump-databases.sh
echo "== Backup"
borg create \
--verbose --stats --show-rc \
--compression zstd,3 \
--one-file-system \
--exclude-caches \
--exclude '/proc' --exclude '/sys' --exclude '/dev' --exclude '/run' \
--exclude '/tmp' --exclude '/var/tmp' --exclude '/var/cache' \
--exclude '/var/lib/mysql' --exclude '/var/lib/postgresql' \
--exclude '/var/lib/docker/overlay2' --exclude '/var/lib/docker/image' \
--exclude '*/node_modules' --exclude '*/.cache' --exclude '*.log' \
::'{hostname}-{now:%Y-%m-%d_%H%M}' \
/etc /root /home /var/www /opt /var/backups/db /var/lib/docker/volumes
echo "== Retention"
borg prune \
--list --show-rc \
--keep-daily 14 \
--keep-weekly 8 \
--keep-monthly 12 \
--keep-yearly 2
echo "== Compacting"
borg compact
echo "Backup finished: $(date)"
sudo chmod 700 /usr/local/bin/borg-backup.sh
sudo /usr/local/bin/borg-backup.sh
Since Borg 1.2, prune no longer frees space by itself: compact is essential, otherwise the repository only ever grows.
Add a full verification once a week (it can take a while on a large repository):
sudo -E borg check --verify-data
Browse and Restore
source /etc/borg/env
# List archives
borg list
# Contents of an archive
borg list ::vps1-2026-09-08_0300 /var/www
# Restore a specific file into the current directory (Borg restores relative paths)
cd /tmp && mkdir restore && cd restore
borg extract ::vps1-2026-09-08_0300 var/www/monsite.fr/wp-config.php
# Restore everything (from / to get the original paths back)
cd / && borg extract ::vps1-2026-09-08_0300
# Mount the whole repository
mkdir -p /mnt/borg && borg mount :: /mnt/borg
ls /mnt/borg/
borg umount /mnt/borg
Ransomware Protection: Append-Only Mode
This is one of Borg's major strengths, and it fits on a single line. On the VPS Storage, edit the key line in /home/backup/.ssh/authorized_keys to force the command that gets executed:
command="borg serve --append-only --restrict-to-path /srv/backups/vps1-borg",restrict ssh-ed25519 AAAA... backup@vps1
From now on, the source VPS can add archives but can never truly erase anything: a borg prune or borg delete run from the client appears to succeed, but the data stays in the repository and the operation can be undone on the server by rolling back to an earlier transaction (see the append-only mode section of the Borg documentation). An attacker who compromises the VPS therefore cannot destroy your history.
Retention is then handled from the storage server, for example every Sunday, with direct access to the repository:
# On the VPS Storage, as the backup user
export BORG_PASSCOMMAND="cat /home/backup/.passphrase-vps1"
borg prune --keep-daily 14 --keep-weekly 8 --keep-monthly 12 /srv/backups/vps1-borg
borg compact /srv/backups/vps1-borg
Simply remove --append-only from the command= line during an exceptional operation if needed.
Step 3: Automate with a systemd Timer
A systemd timer is more reliable than cron for this job: logs go to journalctl, a missed run (VPS powered off at the scheduled time) is caught up at boot, and two backups never overlap.
sudo nano /etc/systemd/system/backup.service
[Unit]
Description=Offsite backup (Restic or Borg)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# Pick one of the two scripts
ExecStart=/usr/local/bin/restic-backup.sh
#ExecStart=/usr/local/bin/borg-backup.sh
Nice=10
IOSchedulingClass=idle
sudo nano /etc/systemd/system/backup.timer
[Unit]
Description=Daily offsite backup
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
Nice=10 and IOSchedulingClass=idle ensure the backup yields priority to your applications if it lands during a traffic spike. RandomizedDelaySec prevents all your VPS from hitting the storage server at the exact same second.
To run a backup by hand and follow its log:
sudo systemctl start backup.service
sudo journalctl -u backup.service -f
Step 4: Get Notified When It Fails
A backup that fails silently for three months is worse than no backup at all: you believe you are protected. Add a failure notification with a service triggered by OnFailure:
sudo nano /etc/systemd/system/backup-notify.service
[Unit]
Description=Backup failure notification
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-backup-failed.sh
sudo nano /usr/local/bin/notify-backup-failed.sh
#!/bin/bash
# Discord: create a webhook in the channel settings
WEBHOOK="https://discord.com/api/webhooks/xxxx/yyyy"
LOGS=$(journalctl -u backup.service -n 15 --no-pager | tail -c 1500)
curl -s -H "Content-Type: application/json" \
-d "{\"content\": \"❌ **Backup failed on $(hostname)**\n\`\`\`\n${LOGS}\n\`\`\`\"}" \
"$WEBHOOK"
sudo chmod 700 /usr/local/bin/notify-backup-failed.sh
And in backup.service, [Unit] section:
OnFailure=backup-notify.service
sudo systemctl daemon-reload
Also consider the opposite problem: a timer that no longer fires (service disabled by mistake, VPS reinstalled) produces no error at all. A "dead man's switch" service such as Healthchecks.io (free for a handful of checks) alerts you when an expected ping does not arrive: add curl -fsS -m 10 https://hc-ping.com/your-uuid at the end of the backup script.
Step 5: Test the Restore (For Real)
This is the step everyone skips, and it is the only one that matters. Once a quarter, or after any configuration change:
- Order a trial VPS or use a local VM.
- Install Restic or Borg, copy the password file (and the exported key for Borg).
- Restore the latest full snapshot.
- Re-import the database dump:
gunzip < /var/backups/db/mariadb-all.sql.gz | mysql. - Start the application and check that it works.
- Note how long it took you: that is your real RTO, the one you will announce in the event of a disaster.
If any of these steps gets stuck, you have just discovered a problem on the right day, the one where nothing is on fire.
Checklist
- Offsite destination up and running, dedicated user, SSH key without passphrase
- Database dump before every backup
- Repository initialized and encrypted; password (and Borg key) saved outside the VPS
- Correct exclusions: no raw
/var/lib/mysql, no caches - Retention defined (
forget/prune) and compacting (compactfor Borg) - systemd timer active,
Persistent=true - Failure notification + dead man's switch
- Append-only mode or equivalent against ransomware
- Regular integrity verification (
check) - Restore tested and timed
Troubleshooting
Fatal: unable to open repository / Repository does not exist
The repository path is wrong or the SSH connection fails. Test with ssh storage "ls -la /srv/backups". For Borg, check that borgbackup is indeed installed on the server side: ssh storage "borg --version".
repository is already locked (Borg) / unable to create lock (Restic)
A previous backup was interrupted. Check that no process is still running (pgrep -a borg or pgrep -a restic), then borg break-lock :: or restic unlock.
The Backup Is Slow
The first one always is (everything gets sent). After that, check the compression (zstd rather than lzma), and above all the exclusions: large logs or a cache that changes on every run break deduplication. restic backup --dry-run -v or borg create --dry-run --list show what is being sent.
The Repository Keeps Growing
Restic: forget without --prune frees nothing. Borg: prune without compact does not either. Also check that no backup includes both the dump directory and the raw database files.
borg: error: unrecognized arguments or Version Mismatch
Client and server have different major versions. Align them (install the same version on both sides, possibly via pip install borgbackup==1.4.*).
Restore: Wrong Permissions or Owners
Restic and Borg restore metadata when the extraction is run as root. With another user, the files end up owned by that user.
The YorkHost VPS Storage (500 GB or 1 TB of Enterprise SSD) is the natural destination for Borg or Restic: full SSH access for borg serve or rest-server, 1 to 10 Gbps network to your other VPS, Stormwall & Gcore Anti-DDoS included, flat monthly price with no request or egress fees, and no commitment. Combined with the 7-day rolling backups included with every Linux VPS, you get a genuine 3-2-1 strategy for a few euros a month.