Here's the uncomfortable truth about most automated backup setups: the server being backed up holds credentials to its own backup destination. If that server gets compromised by ransomware, a stolen SSH key, or a malicious script, the attacker can delete every backup you've ever made before encrypting your data. Your "backups" were never really backups. They were a copy that lived exactly as long as the attacker allowed.
BorgBackup solves this with append-only mode: the backup server accepts new data from the client but refuses to let it destroy old data. Even with full access to the backup credentials, an attacker can't erase your history. Combined with Borg's client-side encryption, deduplication, and compression, you get offsite backups that are private, space-efficient, and genuinely recoverable on your worst day.
This guide walks through the full setup: a restricted backup user, an SSH key locked to append-only mode, an encrypted repository, nightly automation, and the monthly maintenance routine that keeps the repo healthy.
Why BorgBackup
- Deduplication. Borg splits files into chunks and stores each unique chunk once. Thirty nightly backups of a 100GB server typically consume little more than 100GB plus whatever actually changed, not 3TB.
- Client-side encryption. Data is encrypted before it leaves your server. The backup host stores ciphertext only and never sees the key.
- Compression. zstd compression is built in. Text-heavy data (code, logs, dumps) often shrinks 3–5x.
- Point-in-time archives. Every run creates a named archive. You can list, mount, or extract the state of any directory from any night.
- Append-only SSH repositories. The feature this guide is built around: the remote enforces that clients can only add data, not destroy it.
What You Need
- Source machine: the Linux server you want to back up
- Destination VPS: a BulkVM VPS, or any Linux server you control with root access and enough disk
- BorgBackup 1.2 or newer on both machines. 1.4 is the current stable series, and whatever your distro's repo ships will work
Root access on the destination matters here. Append-only mode is enforced server-side in the authorized_keys file, so you need to control the destination's SSH configuration. A plain SFTP-only storage account can't do this.
borg repo-create instead of borg init) but the concepts are the same.Step 1: Install BorgBackup on Both Machines
On Debian or Ubuntu (run on both the source and the destination):
sudo apt install borgbackup
Verify the version on each machine:
borg --version
Anything 1.2 or newer is fine on both sides. Current repos ship 1.2.x through 1.4.x depending on the distro release, and mismatched 1.x versions interoperate; the client and server negotiate.
Step 2: Create a Restricted Backup User on the VPS
Don't back up to root. Create a dedicated user on the destination VPS whose only job is holding repositories:
sudo useradd -m -s /bin/bash borg
sudo -u borg mkdir /home/borg/repos
If your storage volume is mounted somewhere like /mnt/data, put the repos there instead and give the borg user ownership:
sudo mkdir -p /mnt/data/repos
sudo chown borg:borg /mnt/data/repos
sudo rmdir /home/borg/repos # remove the empty dir from the previous step
sudo ln -s /mnt/data/repos /home/borg/repos
Borg resolves symlinks when enforcing path restrictions, so the --restrict-to-path /home/borg/repos setting in the next step works unchanged.
If your HDD volume isn't mounted yet, see our guide on formatting and mounting additional storage first.
Step 3: Lock the SSH Key to Append-Only Mode
This is the step that makes the setup ransomware-resistant. On the source machine, generate a dedicated key:
ssh-keygen -t ed25519 -f ~/.ssh/borg_key -N "" -C "borg-backup"
Now install the public key on the destination, but instead of a normal authorized_keys entry, prefix it with a forced command. On the destination VPS, open the borg user's key file:
sudo -u borg mkdir -p /home/borg/.ssh
sudo nano /home/borg/.ssh/authorized_keys
Add one line: the forced command, then the contents of ~/.ssh/borg_key.pub from the source machine:
command="borg serve --restrict-to-path /home/borg/repos --append-only",restrict ssh-ed25519 AAAA...your-key-here... borg-backup
Then fix permissions:
sudo chown -R borg:borg /home/borg/.ssh
sudo chmod 700 /home/borg/.ssh
sudo chmod 600 /home/borg/.ssh/authorized_keys
Three things are happening in that one line:
command="borg serve ...": no matter what the client asks for, SSH runs only this command. The key can't open a shell, copy files, or do anything except talk to Borg.--restrict-to-path: the key can only touch repositories under/home/borg/repos.--append-only: destructive operations from this key don't actually destroy data. This is your ransomware insurance.
Step 4: Initialize the Encrypted Repository
Back on the source machine, tell Borg which SSH key to use, then create the repository:
export BORG_RSH="ssh -i ~/.ssh/borg_key"
borg init --encryption=repokey-blake2 ssh://borg@YOUR_VPS_IP/./repos/web1
Borg will prompt you for a passphrase. repokey-blake2 mode stores the encryption key inside the repository, protected by this passphrase, so the passphrase is what stands between an attacker who has the repo files and your data.
Now do the single most important step in this guide. Export the key and store it somewhere that is not the source server and not the backup server:
borg key export ssh://borg@YOUR_VPS_IP/./repos/web1 ~/web1-borg-key.txt
Put the contents of that file and your passphrase in your password manager, then delete the file. If the source server dies and you don't have the key and passphrase, the backups are mathematically unrecoverable. Borg cannot help you, and neither can we.
Step 5: Run Your First Backup
Set the repo and passphrase in the environment, then create an archive:
export BORG_REPO='ssh://borg@YOUR_VPS_IP/./repos/web1'
export BORG_PASSPHRASE='your-passphrase-here'
export BORG_RSH='ssh -i ~/.ssh/borg_key'
borg create --stats --compression zstd,3 \
::'{hostname}-{now}' \
/etc /home /var/www
{hostname}-{now} names each archive automatically, like web1-2026-06-09T03:00:01. The --stats output on the first run shows you the dedup ratio immediately: "Deduplicated size" is what actually landed on the remote disk.
Run it a second time and watch the numbers: the second archive typically transfers a few megabytes, because Borg only sends chunks it hasn't seen before.
MySQL/MariaDB:
mysqldump -u root --all-databases > /var/backups/db.sqlPostgreSQL:
pg_dumpall -U postgres > /var/backups/db.sqlThen add
/var/backups/db.sql to your borg create paths.Step 6: Automate with a Script and Cron
Create the backup script on the source machine:
nano /usr/local/bin/borg-backup.sh
#!/bin/bash
set -e
export BORG_REPO='ssh://borg@YOUR_VPS_IP/./repos/web1'
export BORG_PASSPHRASE='your-passphrase-here'
export BORG_RSH='ssh -i /root/.ssh/borg_key'
# Dump databases first
mysqldump -u root --all-databases > /var/backups/db.sql
# Create tonight's archive
borg create --stats --compression zstd,3 \
::'{hostname}-{now}' \
/etc /home /var/www /var/backups/db.sql
# Flag old archives for removal (deferred until compact; see below)
borg prune --list \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6
The script contains your passphrase, so lock it down and make it executable:
chmod 700 /usr/local/bin/borg-backup.sh
Schedule it nightly at 3 AM:
crontab -e
0 3 * * * /usr/local/bin/borg-backup.sh >> /var/log/borg-backup.log 2>&1
What Append-Only Actually Does (and Doesn't)
This part trips people up, so it's worth being precise.
In append-only mode, operations that would delete data (borg prune, borg delete, even borg init over an existing repo) appear to succeed from the client's side, but the repository only ever appends to its transaction log. Nothing is physically removed. Disk space is not freed. Everything remains recoverable by rolling the repository back to an earlier transaction (the Borg documentation covers the rollback procedure).
So if ransomware gets your server and runs borg delete --glob-archives '*' with your stolen key, it accomplishes nothing permanent. You roll back the transaction log from the VPS and your archives are back.
The flip side: your nightly borg prune doesn't free space either. Deletions only become real when borg compact runs, and because the append-only restriction lives in the SSH key, not the repository itself, you run compact locally on the VPS, where the restricted key has no say. That's the design: the exposed credential can only add; the cleanup power stays on a machine the source server can't touch.
Monthly Maintenance: Check, Then Compact
Once a month, do two things. First, from the source machine (or anywhere with the passphrase), confirm the archive list looks right: the expected nightly archives exist and nothing surprising is missing:
borg list
This check matters: compacting makes any pending deletions permanent, including malicious ones. Thirty seconds of eyeballing the list before compacting is what turns "recoverable" into "recovered."
Then, on the destination VPS, verify repository consistency and free the space from pruned archives:
sudo -u borg borg check --repository-only /home/borg/repos/web1
sudo -u borg borg compact /home/borg/repos/web1
Neither command needs your passphrase; they operate on the repository structure, not the encrypted contents. If you'd rather not think about it, drop both lines into the VPS's crontab on the 1st of the month, and keep the habit of glancing at borg list when you check your backup logs.
compact runs while a nightly borg create is mid-transfer, one of them will fail with a lock timeout. If your backups run at 3 AM, run maintenance at noon.Restoring Files
List the archives in the repository:
borg list
List the contents of one archive:
borg list ::web1-2026-06-09T03:00:01 | head -20
Extract a specific path (restores into the current directory):
cd /tmp/restore
borg extract ::web1-2026-06-09T03:00:01 var/www/html
Or mount an entire archive as a read-only filesystem and browse it like a normal directory:
mkdir -p /mnt/borg
borg mount ::web1-2026-06-09T03:00:01 /mnt/borg
ls /mnt/borg/var/www
# when done:
borg umount /mnt/borg
borg mount requires the python3-llfuse package on most distros (sudo apt install python3-llfuse).
Do a test restore now, while nothing is on fire. A backup you've never restored from is a hypothesis, not a backup.
Quick Reference
# --- Destination VPS (once) ---
sudo useradd -m -s /bin/bash borg
sudo -u borg mkdir /home/borg/repos
# /home/borg/.ssh/authorized_keys:
# command="borg serve --restrict-to-path /home/borg/repos --append-only",restrict ssh-ed25519 AAAA... borg-backup
# --- Source machine (once) ---
ssh-keygen -t ed25519 -f ~/.ssh/borg_key -N "" -C "borg-backup"
export BORG_RSH="ssh -i ~/.ssh/borg_key"
borg init --encryption=repokey-blake2 ssh://borg@YOUR_VPS_IP/./repos/web1
borg key export ssh://borg@YOUR_VPS_IP/./repos/web1 ~/web1-borg-key.txt # store offline!
# --- Nightly (cron) ---
borg create --stats --compression zstd,3 ::'{hostname}-{now}' /etc /home /var/www
borg prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
# --- Monthly, on the VPS (frees space, makes prunes permanent) ---
borg list # sanity check from a trusted machine first
sudo -u borg borg check --repository-only /home/borg/repos/web1
sudo -u borg borg compact /home/borg/repos/web1
# --- Restore ---
borg list
borg extract ::ARCHIVE-NAME path/to/restore
borg mount ::ARCHIVE-NAME /mnt/borg
A Borg repository needs a home
2TB of ZFS storage with full root access for $5/month: flat rate, no egress fees, and the root access you need to enforce append-only mode.