BorgBackup (Borg) is a deduplicating backup program: it splits files into chunks, stores each unique chunk only once, compresses it and encrypts it on the client before it leaves the server. Nightly backups of a server with little change take seconds and a few megabytes, while every archive can still be restored as a full copy. In this tutorial you will back up an Ubuntu 24.04 server to a Borg repository on a second server over SSH, automate it with a systemd timer, apply a retention policy and restore files.

Prerequisites

To follow this guide you need:

  • A client server running Ubuntu 24.04 LTS (for example a CubePath VPS) with a non-root user with sudo privileges. This is the server you want to back up.
  • A backup server running Ubuntu 24.04 or Debian 12 with SSH access, a sudo user and enough disk space, ideally in a different data center. Its address is referred to as your_backup_server_ip.
  • A password manager or other safe place outside both servers to store the repository key and passphrase.

Step 1 - Installing BorgBackup on both servers

Borg must be installed on the client and on the backup server, because the client talks to a borg serve process on the remote side. Run this on both servers:

sudo apt update
sudo apt install borgbackup

Check the version:

borg --version
borg 1.2.8

Ubuntu 24.04 ships Borg 1.2. Keep both servers on the same major version (1.x); Borg 2 uses a different, incompatible repository format.

Step 2 - Preparing the repository user on the backup server

On the backup server, create a dedicated user whose home directory holds the repositories:

sudo useradd --create-home --home-dir /srv/borg --shell /bin/bash borg
sudo chmod 700 /srv/borg
sudo -u borg mkdir -m 700 /srv/borg/.ssh

useradd leaves the account without a password, so it can only log in with an SSH key.

Step 3 - Creating an SSH key restricted to Borg

The backup will run as root on the client server so it can read every file. Create a dedicated key for it without a passphrase, so it can run unattended:

sudo ssh-keygen -t ed25519 -f /root/.ssh/borg_ed25519 -N "" -C "borg@$(hostname -s)"
sudo cat /root/.ssh/borg_ed25519.pub

On the backup server, open the authorized_keys file of the borg user:

sudo -u borg nano /srv/borg/.ssh/authorized_keys

Add the public key on a single line, prefixed with a forced command. This key can then only run borg serve, and only inside /srv/borg/web01:

command="borg serve --restrict-to-path /srv/borg/web01",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... borg@web01

Replace web01 with a name for the client, and use one line with its own path for every client you add later. Then fix the permissions:

sudo chmod 600 /srv/borg/.ssh/authorized_keys

Step 4 - Initializing an encrypted repository

The rest of the commands on the client run as root. Open a root shell so the environment variables below apply to every command:

sudo -i

Tell Borg where the repository is and which SSH key to use:

export BORG_REPO='ssh://borg@your_backup_server_ip/srv/borg/web01'
export BORG_RSH='ssh -i /root/.ssh/borg_ed25519'

Accept the backup server's host key once:

ssh -i /root/.ssh/borg_ed25519 borg@your_backup_server_ip

Answer yes to the fingerprint prompt. Because of the forced command, the session does not open a shell; press Ctrl+C to exit.

Now create the repository with the repokey-blake2 mode, which encrypts all data with a key stored inside the repository and protected by your passphrase:

borg init --encryption=repokey-blake2

Enter a long passphrase twice. Borg creates the /srv/borg/web01 directory on the backup server and prints a reminder that you need both the key and the passphrase to access the repository.

Export the key and store it, together with the passphrase, somewhere outside both servers. If the repository's key is ever damaged, this exported copy is the only way to read your backups:

borg key export "$BORG_REPO" /root/borg-web01.key
cat /root/borg-web01.key

Copy the output into your password manager, then delete the local file:

rm /root/borg-web01.key

For unattended runs, store the passphrase in a file only root can read:

install -m 600 /dev/null /root/.borg-passphrase
nano /root/.borg-passphrase

Type the passphrase on the first line and save. Then tell Borg to read it from there:

export BORG_PASSCOMMAND='cat /root/.borg-passphrase'

Step 5 - Creating your first backup

borg create takes an archive name and a list of paths. The :: prefix means "the repository in BORG_REPO", and {hostname} and {now} are placeholders Borg fills in:

borg create --stats --compression zstd,3 --exclude-caches \
  --exclude 'sh:home/*/.cache' \
  --exclude 'sh:root/.cache' \
  ::'{hostname}-{now:%Y-%m-%dT%H:%M}' \
  /etc /home /root /var/www

The options do the following:

  • --compression zstd,3: fast compression with a good ratio. Use lz4 for the lowest CPU usage.
  • --exclude-caches: skip directories marked with a standard CACHEDIR.TAG file.
  • --exclude 'sh:...': shell-style patterns. Borg stores paths without the leading slash, so patterns start with home/, not /home/.

The statistics at the end look like this:

------------------------------------------------------------------------------
Repository: ssh://borg@your_backup_server_ip/srv/borg/web01
Archive name: web01-2026-09-25T10:20
Time (start): Thu, 2026-09-25 10:20:03
Time (end):   Thu, 2026-09-25 10:21:47
Duration: 1 minutes 44.12 seconds
Number of files: 48213
------------------------------------------------------------------------------
                       Original size      Compressed size    Deduplicated size
This archive:                2.14 GB            964.20 MB            912.51 MB
All archives:                2.14 GB            964.20 MB            912.51 MB
------------------------------------------------------------------------------

Run the same command again. The second archive finishes much faster, and its Deduplicated size is only a few megabytes, because almost every chunk already exists in the repository.

If the server runs a database, dump it before the backup (for example with mysqldump --single-transaction) into a directory that is part of the backup. Borg then deduplicates the dumps too.

Step 6 - Listing and restoring archives

List the archives in the repository:

borg list
web01-2026-09-25T10:20               Thu, 2026-09-25 10:20:03 [3c9e1f0a...]
web01-2026-09-25T10:24               Thu, 2026-09-25 10:24:11 [8b2d77e4...]

List the contents of one archive, filtered to a path:

borg list ::web01-2026-09-25T10:24 etc/nginx

borg extract restores into the current directory, recreating the full path. Always restore into an empty directory first:

mkdir -p /tmp/borg-restore && cd /tmp/borg-restore
borg extract ::web01-2026-09-25T10:24 etc/nginx
diff -r /etc/nginx /tmp/borg-restore/etc/nginx && echo "identical"
identical

Ownership and permissions are restored as well, because you ran the command as root. Once you have checked the files, copy what you need back into place.

Step 7 - Pruning old archives

Without a retention policy, the repository keeps every archive forever. borg prune deletes archives that fall outside the policy, and borg compact (required since Borg 1.2) frees the disk space they used. Preview what would be removed:

borg prune --list --dry-run --glob-archives '{hostname}-*' \
  --keep-daily 7 --keep-weekly 4 --keep-monthly 6

This keeps the last 7 daily, 4 weekly and 6 monthly archives. --glob-archives '{hostname}-*' limits pruning to this server's archives. When the list looks right, run it without --dry-run and compact:

borg prune --list --glob-archives '{hostname}-*' \
  --keep-daily 7 --keep-weekly 4 --keep-monthly 6
borg compact

Step 8 - Automating backups with a systemd timer

Put the steps into a script. It treats Borg's exit code 1 (warnings, for example a file that changed while being read) as success, and fails on anything higher:

nano /usr/local/sbin/borg-backup
#!/usr/bin/env bash
set -euo pipefail

export BORG_REPO='ssh://borg@your_backup_server_ip/srv/borg/web01'
export BORG_RSH='ssh -i /root/.ssh/borg_ed25519 -o BatchMode=yes'
export BORG_PASSCOMMAND='cat /root/.borg-passphrase'

rc=0
borg create --stats --compression zstd,3 --exclude-caches \
  --exclude 'sh:home/*/.cache' \
  --exclude 'sh:root/.cache' \
  ::'{hostname}-{now:%Y-%m-%dT%H:%M}' \
  /etc /home /root /var/www || rc=$?

if [ "$rc" -gt 1 ]; then
  echo "borg create failed with exit code $rc" >&2
  exit "$rc"
fi

borg prune --list --glob-archives '{hostname}-*' \
  --keep-daily 7 --keep-weekly 4 --keep-monthly 6
borg compact

If you enabled --append-only in Step 3, remove the prune and compact lines: those operations must then run on the backup server.

Make the script executable by root only:

chmod 700 /usr/local/sbin/borg-backup

Create the service unit:

nano /etc/systemd/system/borg-backup.service
[Unit]
Description=BorgBackup to the backup server
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/borg-backup
Nice=10
IOSchedulingClass=idle

And the timer:

nano /etc/systemd/system/borg-backup.timer
[Unit]
Description=Nightly BorgBackup

[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=20m
Persistent=true

[Install]
WantedBy=timers.target

Enable the timer, run the service once and check the result:

systemctl daemon-reload
systemctl enable --now borg-backup.timer
systemctl start borg-backup.service
journalctl -u borg-backup.service -n 30 --no-pager

The journal shows the --stats block and the list of pruned archives, ending with Finished borg-backup.service. systemctl list-timers borg-backup.timer shows the next scheduled run.

Step 9 - Verifying repository integrity

borg check verifies the repository structure and archive metadata. Run it now and then monthly:

borg check

It prints nothing and exits with code 0 when everything is consistent. borg check --verify-data also decrypts and verifies every chunk, which reads the whole repository and takes much longer; run it occasionally, outside backup hours.

Exit the root shell when you are done:

exit

Troubleshooting

  • Repository path not allowed: the path in BORG_REPO does not match the --restrict-to-path value in authorized_keys.
  • Failed to create/acquire the lock: another Borg process is running, or a previous run was killed. If you are sure nothing is running, remove the stale lock with borg break-lock.
  • Cache is newer than repository or a warning that the repository was relocated: the repository was changed by another client or moved. Borg asks for confirmation interactively; run it once by hand from a root shell and answer the prompt.
  • passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect: the file contains a different passphrase or a trailing character. Recreate it with nano and try borg list by hand.

Conclusion

Your server now sends encrypted, compressed and deduplicated backups to a second server every night, keeps a predictable history and can restore any file from any archive. The key export in your password manager makes the backups recoverable even if both servers are lost. As next steps, enable --append-only and prune from the backup server, add an offsite copy to object storage with restic, and review the 3-2-1 backup rule to schedule regular restore tests.