VPS Snaps

How to back up a server with BorgBackup

BorgBackup (borg) makes deduplicated, compressed and encrypted backups into a repository on a second server that also runs Borg, reached over SSH. Create the repository once with borg init --encryption=repokey, export the key and keep it off both servers, then run borg create, borg prune and borg compact from cron. Borg 1.x does not write to S3 or other object storage itself.

9 min readUpdated Checked against official documentation

What Borg does, and where backups live

Borg splits files into chunks and stores each chunk once, so a nightly backup of a mostly unchanged server adds only what changed. Chunks are compressed (lz4 by default) and, with the encryption modes below, encrypted and authenticated before they leave the server. The repository is a directory: on a local or mounted disk, or on another server where SSH starts borg serve. That server needs Borg installed, and sees neither your passphrase nor your files.

Borg 1.x cannot write to an S3 bucket. If you want backups in object storage without a second server, use restic or rclone. The Borg FAQ allows copying a repository elsewhere with a tool like rclone, but warns never to use the copy and the original as two live repositories.

This guide uses Borg 1.4, the stable series (1.4.5, July 2026). Borg 2.0 is still in beta (2.0.0b25, September 2026) and uses a new repository format that 1.x archives reach through borg transfer. Ubuntu 24.04's package is Borg 1.2.8, which has every command used here.

Install Borg on both servers

Terminal
sudo apt install borgbackup

Run it on the server being backed up and on the backup server, then check borg --version on each. For 1.4, the project publishes standalone binaries on its GitHub releases page; copy the one for your platform to /usr/local/bin/borg, owned by root with mode 755.

Prepare the backup server

On the backup server, create a user that owns the repositories:

Terminal
sudo useradd --create-home --shell /bin/bash borg
Terminal
sudo -u borg mkdir -m 700 /home/borg/repos /home/borg/.ssh

On the server being backed up (here web1), create an SSH key used only for Borg, and print its public half:

Terminal
sudo ssh-keygen -t ed25519 -f /root/.ssh/borg_ed25519 -N '' -C 'borg-web1'
Terminal
sudo cat /root/.ssh/borg_ed25519.pub

Back on the backup server, put it in /home/borg/.ssh/authorized_keys (owned by borg, mode 600) as one line:

/home/borg/.ssh/authorized_keys
command="borg serve --restrict-to-repository /home/borg/repos/web1",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...rest-of-key borg-web1

command= forces every login with this key to run borg serve, so the key cannot open a shell. --restrict-to-repository limits it to exactly that repository, which need not exist yet. restrict turns off port, agent and X11 forwarding and PTY allocation.

No second server? A Hetzner Storage Box runs Borg on its side: see how to use a Hetzner Storage Box for backups.

Create the encrypted repository

Back on web1, as root, store a random passphrase and tell Borg where everything is:

Terminal
sudo sh -c 'head -c 32 /dev/urandom | base64 > /root/.borg-passphrase && chmod 600 /root/.borg-passphrase'
/root/.borg-env
export BORG_REPO='ssh://[email protected]/home/borg/repos/web1'
export BORG_RSH='ssh -i /root/.ssh/borg_ed25519'
export BORG_PASSCOMMAND='cat /root/.borg-passphrase'
Terminal
sudo -i
Terminal
. /root/.borg-env
Terminal
borg init --encryption=repokey
  • BORG_REPO is the default repository, so commands can omit it and archives are written ::name. Use the same absolute path as in authorized_keys.
  • BORG_RSH replaces ssh, here to pick the Borg key.
  • BORG_PASSCOMMAND runs a command and uses its output as the passphrase. It runs without a shell, so $HOME works but ~ does not.
  • repokey stores the key inside the repository, encrypted with your passphrase; the Borg docs' rule of thumb is repokey with a strong passphrase. On CPUs without SHA-256 hardware acceleration, repokey-blake2 is often faster. The mode cannot be changed later.

The first connection asks you to accept the backup server's SSH host key, so run borg init by hand before any cron job.

Export the key

Opening the repository takes both the key and the passphrase. With repokey the key lives in the repository, and a damaged key file there locks you out. Export it and store it, with the passphrase, somewhere that is neither server:

Terminal
borg key export --paper "$BORG_REPO"

Without the key and the passphrase, nobody can read the repository, you included. Keeping the only copy of the passphrase in /root/.borg-passphrase means losing web1 loses the backups too. Put both in a password manager.

The Borg docs note that if the passphrase is stored on the server being backed up, --encryption=keyfile gives the same or better security: the key stays in ~/.config/borg/keys on the client, so the repository alone is useless to an attacker. You must then back up that key file, since the repository no longer holds it.

Create a backup

Terminal
borg create --stats --compression zstd,3 --one-file-system --exclude-caches --exclude-from /etc/borg-excludes ::'{hostname}-files-{now:%Y-%m-%d_%H%M}' /etc /var/www /root
  • ::'{hostname}-files-{now:%Y-%m-%d_%H%M}' names the archive, here web1-files-2026-10-03_0230. Names must be unique. The quotes stop the shell touching the braces.
  • --compression zstd,3 uses zstd at level 3. lz4 (the default) is faster with less compression; auto,zstd,7 skips data that does not compress.
  • --one-file-system skips mount points below the listed paths. --exclude-caches skips directories holding a CACHEDIR.TAG file.
  • --stats prints sizes at the end. This archive's deduplicated size is how much the repository grew.
/etc/borg-excludes
# fnmatch patterns, matched against paths without the leading /
*.log
home/*/.cache
root/.cache
var/www/*/cache

Borg stores /var/www as var/www and strips a leading / from patterns. In this default pattern style * also matches across /, and a pattern that matches a directory excludes everything in it. Test patterns with borg create --list --dry-run. Borg reads each file as it finds it, so dump databases rather than copying their files. --content-from-command saves a command's output and fails the archive if the command fails:

Terminal
borg create --stdin-name mydb.sql --content-from-command ::'{hostname}-db-{now:%Y-%m-%d_%H%M}' -- sudo -u postgres pg_dump mydb

Borg exits 0 on success, 1 on warnings (a file that changed or could not be read during the run) and 2 on errors.

Prune and compact old archives

Terminal
borg prune --list --glob-archives '{hostname}-files-*' --keep-daily 7 --keep-weekly 4 --keep-monthly 6
Terminal
borg compact
  • Without --glob-archives, prune considers every archive in the repository as one series. Give file and database archives different prefixes and prune each with its own glob, or --keep-daily 7 might keep a day's database dump and drop that day's files.
  • --keep-daily 7 keeps the last archive of each of the 7 most recent days with backups; weekly and monthly work the same way. Add --dry-run to preview.
  • Prune only marks data as deleted. borg compact frees the space. It needs no key, so it can run on the backup server too.

Check and test

Terminal
borg check

This checks the repository's segments by size and CRC, on the backup server so little crosses the network, then the archives' metadata on the client. borg check --verify-data also reads, decrypts and verifies every chunk: thorough, and slow. To prove a restore works without writing anything, extract to nowhere:

Terminal
borg extract --dry-run ::web1-files-2026-10-03_0230

List and restore

Terminal
borg list
Terminal
borg list ::web1-files-2026-10-03_0230 var/www/site

The first lists archives, the second files in one archive under a path. borg extract always writes into the current directory, so work in an empty one. Paths have no leading slash:

Terminal
mkdir -p /srv/restore && cd /srv/restore
Terminal
borg extract ::web1-files-2026-10-03_0230 var/www/site/wp-config.php

To browse instead, mount an archive. On Ubuntu this needs the python3-pyfuse3 package. The mount does not restore special file flags or ACLs, so use extract for full restores:

Terminal
borg mount ::web1-files-2026-10-03_0230 /mnt/borg
Terminal
borg umount /mnt/borg

A database archive restores with borg extract --stdout ::web1-db-2026-10-03_0230 > mydb.sql. Then follow how to test a backup restore.

Make the repository append-only

With the setup above, an attacker with root on web1 can run borg delete against its backups. Give web1's key --append-only on the backup server:

/home/borg/.ssh/authorized_keys
command="borg serve --append-only --restrict-to-repository /home/borg/repos/web1",restrict ssh-ed25519 AAAA...web1-key borg-web1
command="borg serve --restrict-to-repository /home/borg/repos/web1",restrict ssh-ed25519 AAAA...admin-key borg-admin

In append-only mode, prune and delete still run and remove archives from the listing, but Borg never overwrites or deletes committed data, and borg compact compacts nothing, without a warning. The repository keeps a transaction log, and you can roll it back to before an attack, as long as borg compact has not run since. The second key, kept on a trusted machine, runs prune and compact. Check borg list before each compact: compacting after an attacker's deletes makes them permanent.

Automate it with cron

/usr/local/bin/borg-backup.sh
#!/bin/sh
. /root/.borg-env

borg create --stats --compression zstd,3 --one-file-system --exclude-caches \
    --exclude-from /etc/borg-excludes \
    ::'{hostname}-files-{now:%Y-%m-%d_%H%M}' /etc /var/www /root
create_rc=$?

borg prune --list --glob-archives '{hostname}-files-*' \
    --keep-daily 7 --keep-weekly 4 --keep-monthly 6
prune_rc=$?

borg compact
compact_rc=$?

rc=$(( create_rc > prune_rc ? create_rc : prune_rc ))
rc=$(( compact_rc > rc ? compact_rc : rc ))
exit "$rc"
Terminal
sudo chmod 700 /usr/local/bin/borg-backup.sh
/etc/cron.d/borg-backup
30 2 * * * root /usr/local/bin/borg-backup.sh >> /var/log/borg-backup.log 2>&1

The script exits with the worst of the three return codes, so 1 means warnings and 2 means something failed. With an append-only key, drop the prune and compact steps and run them from the admin machine. More on schedules in how to schedule backups with cron.

Common errors

ErrorFix
Failed to create/acquire the lock ... (timeout).Another Borg run is active, or a dead SSH session still holds the lock. Borg waits only 1 second by default; add --lock-wait 600. Run borg break-lock only when no Borg process on any machine uses the repository.
Connection closed by remote host. Is borg working on the server?Borg is missing on the backup server or not on its PATH (write the full path, such as /usr/local/bin/borg serve, in authorized_keys), or that line is malformed.
Repository path not allowedThe path in BORG_REPO does not match --restrict-to-repository exactly.
Passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND, or via BORG_PASSPHRASE_FD is incorrect.Wrong passphrase file. Check BORG_PASSCOMMAND and that its command runs as root.
Repository ... does not exist.Run borg init first, or fix the path in BORG_REPO.
Repository disk keeps growingborg compact never ran, or the key is append-only. Compact from the admin key.

Frequently asked questions

Can Borg back up directly to S3?
No. Borg 1.x writes to a local or mounted directory, or to a server running borg serve over SSH. For S3-compatible storage, use restic or rclone.
What is the difference between borg prune and borg compact?
prune deletes archives that fall outside your retention rules. compact rewrites the repository's segment files to actually free the space; since Borg 1.2 it is a separate step.
Which Borg encryption mode should I use?
The docs' rule of thumb is repokey with a strong passphrase, or repokey-blake2 on CPUs without SHA-256 acceleration. Export the key either way.
What does Borg append-only mode protect against?
A compromised client cannot permanently delete backups: its deletes are not compacted, and the repository can be rolled back, provided nobody runs borg compact first.
Should I use Borg 2.0?
In October 2026 it is still a beta. Use the 1.4 series for production backups.

How this was checked

Commands, limits and prices were checked against these official pages, on October 3, 2026: