Skip to main content

Following this tutorial and don't have a VPS yet? Our Linux VPS start at €2.99/month with support included.

See our VPS

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:

  1. Granularity: it is the whole disk or nothing. Recovering a single deleted file means restoring the entire VPS somewhere else.
  2. Retention: 7 days. Silent corruption discovered three weeks later is no longer recoverable.
  3. 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?

ResticBorgBackup
Language, binaryGo, a single static binaryPython + C, package to install
DestinationsSFTP, S3, Backblaze B2, Azure, Google Cloud, rest-server, local, rclone (anything)SSH (with Borg installed on the server side), local
EncryptionAlways on, AES-256Optional, AES-256 or ChaCha20, repokey/keyfile
DeduplicationYes, variable-size blocksYes, variable-size blocks
CompressionYes (auto, zstd) since v0.14Yes, lz4/zstd/zlib/lzma, your choice
SpeedGood, slightly heavier on RAM and requestsExcellent over SSH, very lightweight
Ransomware protectionDepends on the backend (append-only with rest-server, S3 object lock)Native --append-only mode on the SSH server side
Concurrent accessSeveral clients on the same repositoryOne client at a time per repository
Mounting the repositoryrestic mountborg 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:

DestinationAdvantagesThings 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 billingSame 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 availableRestic only, request and egress costs to keep an eye on
A machine at home (NAS, Raspberry Pi)You physically hold the dataSlow 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
Secure the storage server too

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 upWhy
/etcAll system configuration (Nginx, SSH, cron, firewall...)
/home and /rootUser files, scripts, keys
/var/wwwWebsites and web applications
/optManually installed applications, Docker stacks
/var/lib/docker/volumesContainer data (as long as it does not contain a running database)
/var/backups/dbThe 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:

  1. Order a trial VPS or use a local VM.
  2. Install Restic or Borg, copy the password file (and the exported key for Borg).
  3. Restore the latest full snapshot.
  4. Re-import the database dump: gunzip < /var/backups/db/mariadb-all.sql.gz | mysql.
  5. Start the application and check that it works.
  6. 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 (compact for 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.

Offsite storage built for this

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.