A VPS backup is not "an archive sitting somewhere" — it is a procedure: what you copy, where to, how it rotates, and how you confirm the copy actually restores. Below are three working schemes: rsync snapshots on hard links, deduplicating BorgBackup, and restic with an object-storage backend. The scripts are given in full, together with exit-code handling, exclude lists, and the off-site part that usually gets skipped.
What counts as a backup
The reference point is the 3-2-1-1-0 rule:
3 copies of the data, including the live one;
2 different media types or storage systems;
1 copy off-site, physically elsewhere;
1 immutable copy: append-only, offline, or with deletion blocked on the storage side;
0 errors on a test restore — meaning restores are verified regularly, not assumed.
The classic mistake is keeping the "backup" on a second partition of the same VPS. One bad
rm -rf, one ransomware process running as root, one incident on the hypervisor side, and the data and the copy disappear together. In every example below the receiving side is a separate machine,backup-host, not a neighbouring directory.
rsync: snapshots on hard links
The simplest scheme: every run creates its own snapshot directory, and unchanged files are not copied again — they become hard links to the files of the previous snapshot. Deduplication here is per file: change one byte and the whole file is copied. The transport is plain SSH; the receiving side needs only rsync and sshd, no agent. On Ubuntu 24.04 LTS that is rsync 3.2.7.
It is better to run the script on the receiving side and pull from the VPS: a compromised server then has no access to the backup storage, and --link-dest, the latest symlink and rotation all stay local operations. Restrict the key on the VPS side to read-only — rsync ships a wrapper called rrsync exactly for that.
The exclude list
Put excludes in a file rather than on the command line via --exclude={...}: brace expansion exists in bash but not in dash. Run the script as sh /usr/local/sbin/backup.sh and rsync receives the whole list as a single argument, not one pattern matches, and /proc and /sys silently end up in the backup. So, /etc/backup/excludes.txt:
/proc/*
/sys/*
/dev/*
/run/*
/tmp/*
/mnt/*
/media/*
/lost+found
/swapfile
/var/cache/*
/var/lib/mysql/*
/var/lib/postgresql/*
/var/lib/docker/*Why these in particular:
/var/lib/mysql,/var/lib/postgresql— a file-level copy of a running database is inconsistent. Databases are dumped separately, see below;/swapfile— if it exists, that is gigabytes of fully changed data on every run, and hard links do not help at all here;/var/lib/docker— hundreds of thousands of files in overlay2 and a multiple slowdown of the pass, for data that is rebuilt from an image anyway;if the backup storage is mounted on the same server, add its mount point to the list too, or the backup starts copying itself.
The rule is simple: an exclude must point inside something you actually copy. A line like /var/log/journal/* in a list where /var/log is not backed up at all excludes nothing — it only creates false confidence.
The snapshot script
#!/usr/bin/env bash
set -euo pipefail
SRC='root@web-01:/' # pull from the VPS, the script lives on the receiving host
DEST=/srv/backup/web-01 # separate machine or separate storage device
STAMP=$(date +%F_%H%M)
mkdir -p "$DEST/$STAMP"
rc=0
rsync -aAXH -x --numeric-ids \
--exclude-from=/etc/backup/excludes.txt \
--link-dest="$DEST/latest" \
"$SRC" "$DEST/$STAMP/" || rc=$?
# 0 = success, 24 = source files vanished during the copy (normal for a live /)
case "$rc" in
0|24) ;;
*) echo "rsync failed with $rc" >&2; exit "$rc" ;;
esac
ln -sfn "$DEST/$STAMP" "$DEST/latest"The key detail is exit-code handling. When copying a live filesystem, rsync routinely returns 24 ("some files vanished before they could be transferred"): while the pass was running, a PHP session expired and a temporary file was removed. That is normal, not a failure. Under set -e the script dies on that line, ln -sfn never runs, and the latest symlink keeps pointing at the snapshot from two days ago. From then on --link-dest compares against a stale copy, disk space is wasted, and systemd reports failed every day — until nobody looks at the alerts any more. Code 23 (some files could not be read) behaves the same way.
The -x flag (--one-file-system) is mandatory for SRC=/: without it, any filesystem mounted at a point you forgot about — a network disk, an extra volume, a bind mount — is dragged into the backup in full. Before setting things up, look at what is actually mounted with findmnt -o TARGET,SOURCE,FSTYPE and, if needed, cover the extra filesystems with separate runs. There is deliberately no --delete flag here: the destination directory is new and empty every time, so there is nothing to delete, and in examples the flag suggests that old data is cleaned up by itself. Deletion is the rotation script's job and nothing else's.
Rotation
The first idea that comes to mind is find -mtime +30 -exec rm -rf. Do not do that. rsync -a preserves timestamps on the transfer's root directory as well, so /srv/backup/web-01/2026-08-25_0320 gets the mtime of the source /, not the time the backup was made. Two equally bad outcomes follow: if the mtime of / is older than 30 days (perfectly normal on a stable server), the very first rotation run wipes every snapshot at once; if / changed yesterday, nothing is ever deleted and the disk fills up. On top of that, find can delete the directory latest points to, and the next backup silently becomes a full one.
The reliable option is rotating by directory name, which is the timestamp itself:
cd /srv/backup/web-01
KEEP=30
LATEST=$(readlink -f latest || true)
ls -1d 20*_* 2>/dev/null | sort | head -n "-$KEEP" | while read -r d; do
[ "$(readlink -f "$d")" = "$LATEST" ] && continue
rm -rf -- "$d"
done
du -sh /srv/backup/web-01Where the scheme hits its ceiling
no encryption at rest: anyone who reaches the receiving host reads the files as they are;
no compression on disk (
-zonly compresses the transport);per-file deduplication: a rewritten image or an appended log is copied in full;
dozens of snapshots mean millions of inodes, and
rm -rfon an old snapshot or adustarts taking noticeable time;the destination must be a filesystem with hard links and ownership preservation — an SMB share or a FAT disk will not do.
BorgBackup: deduplication and encryption
Borg splits data into variable-length chunks, stores every unique chunk once, and encrypts and compresses the repository. In exchange it requires borg installed on both sides: the client runs borg serve on the receiving host over SSH. On Ubuntu 24.04 LTS the borgbackup package is the 1.2 branch, and every command below is written for it.
Repository, key and passphrase
export BORG_REPO='ssh://backup@backup-host:22/srv/borg/web-01'
borg init --encryption=repokey-blake2
umask 077
head -c 32 /dev/urandom | base64 > /root/.borg-passphrase
chmod 600 /root/.borg-passphrasePermissions deserve a separate thought: the default umask gives the file mode 0644, and the passphrase becomes readable by every user on the system. The same goes for restic:
chmod 600 /root/.borg-passphrase /root/.restic-passIn repokey mode the key is stored inside the repository, so the only secret is the passphrase. A paper copy of it is made like this:
borg key export --paper :: /root/borg-paper-key.txt
# print it, put it in a safe, and remove it from the server
shred -u /root/borg-paper-key.txtWhile those files sit on the server, they must be in the borg create excludes. Otherwise both the passphrase and the paper key silently travel into the very same repository: storing a repository's password inside that repository is pointless, and the key in the archive is an extra leak point, because whoever obtains the repository and the password gets the key along with it. (On journalling filesystems and SSDs shred gives no absolute wiping guarantee, but it does take the file out of sight and out of the next backups.)
The backup script
#!/usr/bin/env bash
set -euo pipefail
export BORG_REPO='ssh://backup@backup-host:22/srv/borg/web-01'
export BORG_PASSCOMMAND='cat /root/.borg-passphrase'
rc=0
borg create --stats --compression zstd,6 --exclude-caches \
--exclude '/root/.borg-passphrase' \
--exclude '/root/borg-paper-key.txt' \
::'{hostname}-{now:%Y-%m-%dT%H:%M:%S}' \
/etc /home /root /opt /srv /usr/local /var/www /var/backups || rc=$?
[ "$rc" -le 1 ] || { echo "borg create failed: $rc" >&2; exit "$rc"; }
borg prune --list --glob-archives '{hostname}-*' \
--keep-daily=7 --keep-weekly=4 --keep-monthly=6
borg compactExit codes in borg 1.2 are: 0 for success, 1 for a warning, 2 for an error. A warning is emitted for things like "file changed while we backed it up" or a file that disappeared — that is, on almost every run on a live server. The archive is still created and valid. But under set -euo pipefail the script aborts on that line: prune never runs, so archives pile up forever; compact never runs, so freed space never returns to the disk; and the unit lands in failed daily. That is why rc <= 1 counts as success.
The seconds in the archive name are not pedantry. With minute granularity, two runs within the same minute (a manual retry after a failure) produce Archive ... already exists and a hard stop.
It is worth reading the source list with your own eyes. If it lacks /opt, /srv, /usr/local and /var/spool/cron, then on a typical VPS you are not backing up exactly where the application data lives. And the other way round: --exclude '/var/cache/*' with sources /etc /home /root excludes nothing at all.
Append-only on the receiving side
This is the most valuable part of the scheme and the most frequently skipped one. If the client reaches the repository with an ordinary key, then root on a compromised VPS deletes the backups with the same command that creates them. The fix is a restricted key on the receiving host — a line in ~backup/.ssh/authorized_keys:
command="borg serve --append-only --restrict-to-repository /srv/borg/web-01",restrict ssh-ed25519 AAAA... web-01-backupWith this key the client can only append. The flip side: prune and compact no longer work from the client, so compaction has to happen separately. Two options:
locally on the receiving host, as the
backupuser:borg prune ... /srv/borg/web-01andborg compact /srv/borg/web-01. Note thatpruneneeds the repository passphrase, which means the passphrase has to live there too — a deliberate trade-off;with a second, non-public key without
--append-only, kept off the production server and used by hand during a maintenance window.
The restic equivalent is rest-server --append-only, or S3 Object Lock plus a bucket policy with Deny on s3:DeleteObject for the key the server uses.
A note on versions
The commands are written for the 1.2 branch. In 1.4 --glob-archives was renamed to --match-archives (the old name still works as an alias, with a warning). In borg 2.x the repository format is incompatible and some commands were renamed — borg init became borg repo-create, and migration goes through borg transfer. Check borg --version before copy-pasting.
restic: one binary and object storage
Repository and backends
restic is a single static Go binary that writes to a local directory, over SFTP, to its own REST server, to S3 and MinIO, Backblaze B2, Azure Blob, Google Cloud Storage, OpenStack Swift, or through rclone (WebDAV only via rclone). The main difference from borg: nothing needs to be installed on the storage side, except for the REST server case — and that is precisely the one that gives you append-only. Ubuntu 24.04 LTS ships a package from the 0.16 branch; a newer version is easiest to drop into /usr/local/bin as a single binary.
umask 077
head -c 32 /dev/urandom | base64 > /root/.restic-pass
chmod 600 /root/.restic-pass
export RESTIC_REPOSITORY='s3:https://s3.example.net/backup-web-01'
export RESTIC_PASSWORD_FILE=/root/.restic-pass
export AWS_ACCESS_KEY_ID='...'
export AWS_SECRET_ACCESS_KEY='...'
restic init --repository-version 2Repository version 2 is what enables compression; recent builds default to it anyway, but the explicit flag removes the doubt.
Backup and retention
#!/usr/bin/env bash
set -euo pipefail
export RESTIC_REPOSITORY='s3:https://s3.example.net/backup-web-01'
export RESTIC_PASSWORD_FILE=/root/.restic-pass
restic backup --tag daily --exclude-caches \
--exclude-file=/etc/backup/restic-excludes.txt \
/etc /home /root /opt /srv /usr/local /var/www /var/backups
restic forget --tag daily \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune/etc/backup/restic-excludes.txt holds only paths inside the source list, otherwise you end up with dead rules:
/root/.restic-pass
/root/.cache
/home/*/.cache
/srv/backupBefore the first real run, always try the retention policy dry:
restic forget --tag daily \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --dry-runforget --prune deletes snapshots irreversibly — exactly like borg prune, and it deserves exactly the same warning. A second subtlety: forget groups snapshots by host,paths by default (--group-by). If the list of backed-up paths ever changed, the policy is applied to each group separately, and more old snapshots survive than you expected.
How much dedup saves: a worked example
This is arithmetic, not a measurement. Take 40 GB of data with roughly 2% changing per day, and 30 daily copies.
Without deduplication: 30 × 40 GB = 1.2 TB.
With chunk-level deduplication: the first snapshot is 40 GB, each later one adds about 0.8 GB, giving 40 + 29 × 0.8 ≈ 64 GB.
The real ratio depends on the nature of the changes: a log that is appended to deduplicates beautifully, while a repacked archive, a VM image or a database dump barely deduplicates at all. Measure on your own data: borg create --stats and restic stats show the actual numbers after a few runs.
Databases are backed up separately
MySQL
umask 077
TMP=$(mktemp /var/backups/app_production.sql.XXXXXX)
mysqldump --single-transaction --quick --routines --events \
app_production > "$TMP"
mv -f "$TMP" /var/backups/app_production.sqlThree reasons not to write plain mysqldump ... > /var/backups/app_production.sql. First, the redirect truncates the file before mysqldump starts: a failed dump leaves a truncated or empty file, which then travels into the backup as usual and displaces the working copy. Second, without --events the scheduler events are silently lost (--triggers, unlike them, is on by default). Third, without umask and mktemp the dump lands with mode 0644 in a directory with mode 0755 — the entire database is readable by any user on the system.
One more caveat that is often skipped: --single-transaction gives a consistent snapshot only for InnoDB, and it breaks if DDL (ALTER, DROP, TRUNCATE) runs concurrently. For MyISAM you need --lock-all-tables, with all the locking that implies.
PostgreSQL
umask 077
TMP=$(mktemp /var/backups/app_production.dump.XXXXXX)
sudo -u postgres pg_dump -Fc app_production > "$TMP"
mv -f "$TMP" /var/backups/app_production.dumpRoles, passwords and tablespaces are not part of pg_dump — they are dumped separately with pg_dumpall --globals-only, using the same mktemp and mv trick.
Checking the dump
A dump that has never been restored is a file, not a backup. Restore it in a throwaway container:
docker run --rm -d --name restore-check \
-e MYSQL_ROOT_PASSWORD=secret mysql:8
docker exec -i restore-check mysql -uroot -psecret \
-e 'CREATE DATABASE restore_check;'
docker exec -i restore-check mysql -uroot -psecret restore_check \
< /var/backups/app_production.sql
docker exec -i restore-check mysql -uroot -psecret restore_check \
-e 'SHOW TABLES;'
docker rm -f restore-checkDo not test restores on the production instance. A line like
mysql restore_check < dump.sqlwith a typo in the database name loads the dump straight over live data, without a single confirmation prompt. If the check still happens on the same server, therestore_checkdatabase must be dropped right after — otherwise it lingers and ends up in the next backup.
DROP DATABASE restore_check;Scheduling
systemd timer
# /etc/systemd/system/borg-backup.service
[Unit]
Description=BorgBackup nightly run
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/borg-backup.sh
Nice=19
IOSchedulingClass=idle
IOWeight=10# /etc/systemd/system/borg-backup.timer
[Unit]
Description=Run BorgBackup nightly
[Timer]
OnCalendar=*-*-* 03:20:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.targetsystemctl daemon-reload
systemctl enable --now borg-backup.timer
systemctl list-timers borg-backup.timer
journalctl -u borg-backup.service -n 50 --no-pagerPersistent=true catches up a run missed while the machine was down, and RandomizedDelaySec spreads out backups from neighbouring servers hitting the same storage. Wants=/After=network-online.target matters for a remote repository: without it the backup starts before the network is up at boot.
There is an unpleasant thing to know about I/O priority: IOSchedulingClass=idle is a valid directive, but ionice classes are implemented only by the BFQ scheduler. On modern kernels, virtual disks default to mq-deadline or none, where the idle class is simply ignored — the priority is quietly never applied. IOWeight= goes through the cgroup v2 I/O controller and does not depend on ionice classes, so it makes sense to set both directives. And if you need a hard ceiling rather than a priority, use IOWriteBandwidthMax=/dev/vda 20M. The start timeout for Type=oneshot is disabled by default, so a multi-hour backup will not be killed.
cron
If systemd is not an option, cron works too, but the line cannot be copied blindly — the format depends on where you put it:
# root crontab: crontab -e
# /etc/cron.d/ and /etc/crontab need an extra user column:
# 20 3 * * * root /usr/local/sbin/borg-backup.sh >> /var/log/borg-backup.log 2>&1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
20 3 * * * /usr/local/sbin/borg-backup.sh >> /var/log/borg-backup.log 2>&1The PATH line is not decoration: cron hands the script PATH=/usr/bin:/bin, and restic in /usr/local/bin fails in that environment with command not found. Set up log rotation right away as well, or the log grows forever — /etc/logrotate.d/borg-backup:
/var/log/borg-backup.log {
weekly
rotate 8
compress
missingok
notifempty
create 0640 root adm
}Restore drills
BorgBackup
mkdir -p /tmp/restore-test && cd /tmp/restore-test
pwd
borg extract --list ::'web-01-2026-08-25T03:20:11' etc/nginxPaths inside an archive are given without a leading slash — that is correct and intended. What borg extract does not have in the 1.2 branch is a --target option: the command always extracts into the current directory. Next to restic restore --target that is far from obvious, and anyone who copies the single borg extract line will happily unpack the archive wherever they happen to stand. From / that means overwriting the live /etc/nginx. The order is: cd first, then pwd to check, and only then extract. To inspect contents without writing to disk, use borg extract --dry-run --list or borg mount.
Comparing the restored copy by content alone is not enough: diff -r does not see owners and permissions, and those are exactly what breaks service startup after a restore.
diff -r /etc/nginx /tmp/restore-test/etc/nginx
diff <(cd /etc/nginx && find . -printf '%p %m %u:%g\n' | sort) \
<(cd /tmp/restore-test/etc/nginx && find . -printf '%p %m %u:%g\n' | sort)restic
restic restore latest --host "$(hostname)" --path /etc \
--target /tmp/restore-test --include /etc/nginxlatest without filters means the last snapshot in the whole repository. If several servers write into one bucket — a typical S3 setup — you restore someone else's /etc/nginx, and diff shows a mismatch you will read as a corrupted backup. Hence --host and --path are mandatory.
Repository integrity
borg check :: # structure and metadata, run regularly
borg check --verify-data :: # full read and decrypt, once a quarter
restic check # structure
restic check --read-data-subset=5% # sampled data verificationborg check --verify-data reads and decrypts the entire repository: on 100 GB that is hours of CPU and a full pass over the disk. It has no place in a daily script — borg check :: without the flag is enough there. restic follows the same logic: a full data check is expensive, so a sample is what you read regularly.
And two matching incantations for when a job was killed on timeout and left the repository locked — otherwise the next run walks straight into Failed to create/acquire the lock:
borg break-lock ::
restic unlockSide by side
| Criterion | rsync + --link-dest | BorgBackup 1.2 | restic 0.16+ |
|---|---|---|---|
| Deduplication | per file, via hard links | chunk-level, variable length | chunk-level, variable length |
| Encryption at rest | none | yes, whole repository | yes, whole repository |
| Compression | none | zstd, lz4, zlib, lzma | zstd, repository format v2 |
| Needed on the receiving side | rsync and sshd | borg of the same branch | nothing for S3/SFTP, REST server optional |
| Off-site | an SSH target | ssh:// and borg serve | S3, B2, Azure, GCS, Swift, SFTP, REST, rclone |
| Append-only | no | borg serve --append-only | rest-server --append-only, S3 Object Lock |
| Pulling one file back | a plain cp from the directory | borg extract, borg mount | restic restore --include, restic mount |
| What it costs you | space: a changed file is copied whole | borg on both sides, manual compact | memory and storage requests during prune |
What to choose
rsync — when you want instant access to files without any tool, the receiving host is yours, and space is not scarce. It is also the best fit for the "second copy nearby" that complements off-site rather than replacing it.
BorgBackup — when space savings and encryption matter, and you control the receiving host well enough to install borg there. Append-only via
borg servetakes exactly one line inauthorized_keys.restic — when backups go to object storage, there are many servers, and installing an agent on the storage side is impossible or unwanted. The price is traffic and request counts during
prune.
The tool is secondary. Any of the three schemes has to reach the same state: a copy off the server, immutable storage, rotation that does not delete too much, exit codes handled — plus a calendar reminder to restore a random snapshot once a month. A scheme you have never restored from is not a backup.







