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
sudoprivileges. This is the server you want to back up. - A backup server running Ubuntu 24.04 or Debian 12 with SSH access, a
sudouser and enough disk space, ideally in a different data center. Its address is referred to asyour_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
TipAdding
--append-onlyafterborg serveprevents this client from deleting or overwriting existing data, which protects your history if the client server is compromised. Pruning must then be done from the backup server itself. Start without it and add it once the setup works.
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. Uselz4for the lowest CPU usage.--exclude-caches: skip directories marked with a standardCACHEDIR.TAGfile.--exclude 'sh:...': shell-style patterns. Borg stores paths without the leading slash, so patterns start withhome/, 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 inBORG_REPOdoes not match the--restrict-to-pathvalue inauthorized_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 withborg break-lock.Cache is newer than repositoryor 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 withnanoand tryborg listby 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.
