BorgBackup (Borg) is a deduplicating backup program with authenticated encryption and configurable compression. It stores backups in a repository that can live on a remote server reached over SSH, where a restricted borg serve process accepts the data. In this tutorial you will back up an Ubuntu 24.04 server to a dedicated backup server using an append-only SSH key, so that an attacker who takes over the client cannot delete its backups. You will then automate the job with systemd, prune old archives safely from a trusted machine and test a restore.
Prerequisites
To follow this tutorial you need:
- A client: the Ubuntu 24.04 server you want to back up, with a non-root user with
sudoprivileges. - A backup server: a second Ubuntu 24.04 machine with enough disk space, for example a CubePath VPS in another location, reachable over SSH as
backup.your_domain. - An admin machine you trust, such as your workstation, with Borg installed. It will be the only place allowed to delete archives.
Borg must be installed on both ends of an SSH connection, ideally in the same major version. Ubuntu 24.04 ships Borg 1.2, and the commands in this guide use Borg 1.x syntax.
Step 1 - Installing Borg on the client and the backup server
Run on both machines:
sudo apt update
sudo apt install borgbackup
borg --version
borg 1.2.8
Step 2 - Preparing the backup server
Create a dedicated user that owns all repositories. It needs a home directory for its SSH keys but will never log in with a shell:
sudo useradd --system --create-home --home-dir /srv/borg --shell /bin/bash borg
sudo install -d -o borg -g borg -m 0700 /srv/borg/.ssh
sudo install -o borg -g borg -m 0600 /dev/null /srv/borg/.ssh/authorized_keys
The account keeps a shell because sshd runs the forced borg serve command through it. Every key you add will be restricted to that single command, so the shell is never reachable interactively.
Step 3 - Creating a restricted SSH key for the client
On the client, generate a key used only for backups. It has no passphrase because the timer runs unattended:
sudo ssh-keygen -t ed25519 -N "" -C "borg@$(hostname)" -f /root/.ssh/borg_ed25519
sudo cat /root/.ssh/borg_ed25519.pub
On the backup server, add that public key to /srv/borg/.ssh/authorized_keys, prefixed with a forced command. Replace web01 with the client's host name and paste the real key:
sudo nano /srv/borg/.ssh/authorized_keys
command="borg serve --append-only --restrict-to-path /srv/borg/web01",restrict ssh-ed25519 AAAAC3Nza...your_client_key borg@web01
Each part matters:
command="borg serve ..."ignores whatever the client asks to run and always starts the Borg server.--append-onlymakes the repository accept new data only. Deletions and prunes from this key are recorded in the transaction log but no data is ever removed.--restrict-to-pathconfines the key to this client's repository, so one compromised client cannot read or touch another's backups.restrictdisables port forwarding, agent forwarding and TTY allocation.
Back on the client, record the backup server's host key. Compare the fingerprint that ssh-keyscan prints with the one shown by sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the backup server:
ssh-keyscan -t ed25519 backup.your_domain | sudo tee -a /root/.ssh/known_hosts
Step 4 - Configuring and initializing the repository
Keep the repository location, SSH options and passphrase in a root-only environment file that both your shell and systemd can load.
Generate the repository passphrase:
sudo install -d -m 0700 /etc/borg
sudo sh -c 'openssl rand -base64 32 > /etc/borg/passphrase'
sudo chmod 600 /etc/borg/passphrase
Create the environment file:
sudo nano /etc/borg/borg.env
BORG_REPO="ssh://[email protected]_domain/srv/borg/web01"
BORG_PASSCOMMAND="cat /etc/borg/passphrase"
BORG_RSH="ssh -i /root/.ssh/borg_ed25519 -o ServerAliveInterval=60 -o ServerAliveCountMax=10"
BORG_PASSCOMMAND reads the passphrase from the file only when Borg needs it, instead of keeping it in an environment variable. The ServerAlive options keep long-running backups from being dropped by idle-connection timeouts.
sudo chmod 600 /etc/borg/borg.env
Open a root shell and load the settings. The remaining client commands assume this shell:
sudo -i
set -a; source /etc/borg/borg.env; set +a
Initialize the repository with repokey-blake2 encryption, which stores the encrypted key inside the repository and uses the fast BLAKE2b hash:
borg init --encryption=repokey-blake2
Borg prints no output on success. Confirm the repository exists:
borg info
Repository ID: 5c3e...
Location: ssh://[email protected]_domain/srv/borg/web01
Encrypted: Yes (repokey BLAKE2b)
...
Export the key and keep it, together with the passphrase, somewhere outside both servers, such as your password manager:
borg key export "$BORG_REPO" /root/borg-web01.key
cat /root/borg-web01.key
WarningYou need both the key and the passphrase to read your backups. If the repository's copy of the key is damaged and you have no export, the data is lost.
Step 5 - Choosing a compression setting
Borg compresses each chunk before encrypting it. The right algorithm depends on your data and CPU:
| Setting | Speed | Ratio | Good for |
|---|---|---|---|
lz4 | Very fast | Low | Default in Borg 1.2; busy servers with little CPU to spare |
zstd,3 | Fast | Good | Most servers: text, configs, database dumps |
zstd,10 | Moderate | Better | Small uplinks where transfer time dominates |
auto,zstd,6 | Varies | Good | Mixed data; skips already-compressed files such as JPEG or zip |
lzma,6 | Slow | High | Cold archives where time does not matter |
auto,... runs a quick test on each chunk and stores incompressible data without compression, which saves CPU on media files. This guide uses auto,zstd,6.
Compression only applies to newly written chunks, so you can change it at any time without affecting existing archives.
Step 6 - Writing the backup script
A short script is the right tool here, because Borg uses exit code 1 for warnings (for example, a file changed while it was being read), which should not mark the job as failed. The script treats only real errors (code 2 and above) as failures.
nano /usr/local/sbin/borg-backup
#!/usr/bin/env bash
set -euo pipefail
# Paths to back up; adjust to your server
paths=(/etc /home /root /opt /var/www /var/backups)
rc=0
borg create \
--stats --show-rc \
--compression auto,zstd,6 \
--one-file-system \
--exclude-caches \
--exclude '/home/*/.cache' \
--exclude '/root/.cache' \
--exclude 'sh:**/node_modules' \
"::{hostname}-{now:%Y-%m-%dT%H:%M:%S}" \
"${paths[@]}" || rc=$?
if (( rc > 1 )); then
echo "borg create failed with exit code ${rc}" >&2
exit "${rc}"
fi
exit 0
The archive name ::{hostname}-{now:...} uses the repository from BORG_REPO and Borg's own placeholders, so each archive is named like web01-2026-09-25T03:00:00. --exclude-caches skips directories that contain a CACHEDIR.TAG file, and --one-file-system keeps Borg out of other mounts.
Make it executable and run it once:
chmod 700 /usr/local/sbin/borg-backup
/usr/local/sbin/borg-backup
------------------------------------------------------------------------------
Repository: ssh://[email protected]_domain/srv/borg/web01
Archive name: web01-2026-09-25T10:42:17
Number of files: 51023
Original size Compressed size Deduplicated size
This archive: 1.72 GB 702.14 MB 688.90 MB
------------------------------------------------------------------------------
terminating with success status, rc 0
NoteBorg copies files, not running databases. Dump MySQL or PostgreSQL to
/var/backups(withmysqldumporpg_dump) before the backup runs so the archive contains a consistent copy.
Step 7 - Scheduling backups with systemd
Create a oneshot service that loads the environment file and runs the script at low priority:
nano /etc/systemd/system/borg-backup.service
[Unit]
Description=BorgBackup to backup.your_domain
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/borg/borg.env
Environment=BORG_CACHE_DIR=/var/cache/borg
CacheDirectory=borg
Nice=10
IOSchedulingClass=idle
ExecStart=/usr/local/sbin/borg-backup
BORG_CACHE_DIR points Borg's local chunk cache at /var/cache/borg, which systemd creates through CacheDirectory. Keeping the cache between runs is what makes incremental backups fast.
Create the timer:
nano /etc/systemd/system/borg-backup.timer
[Unit]
Description=Daily BorgBackup
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=30min
Persistent=true
[Install]
WantedBy=timers.target
Enable the timer and test the service:
systemctl daemon-reload
systemctl enable --now borg-backup.timer
systemctl start borg-backup.service
journalctl -u borg-backup.service -n 15 --no-pager
borg-backup[31022]: terminating with success status, rc 0
systemd[1]: borg-backup.service: Deactivated successfully.
The first run through systemd rebuilds the cache in its new location, so it takes a little longer. Check the next run with systemctl list-timers borg-backup.timer.
Step 8 - Pruning archives from a trusted machine
The client's key is append-only, so any prune it runs frees no space. Retention is applied from your admin machine with a second key that has full access.
On the admin machine, generate a key and show it:
ssh-keygen -t ed25519 -f ~/.ssh/borg_admin -C "borg-admin"
cat ~/.ssh/borg_admin.pub
On the backup server, add it to /srv/borg/.ssh/authorized_keys without --append-only:
command="borg serve --restrict-to-path /srv/borg",restrict ssh-ed25519 AAAAC3Nza...your_admin_key borg-admin
From the admin machine, preview what the retention policy would delete. Borg asks for the repository passphrase, which you paste from your password manager, so it is never stored anywhere else:
export BORG_RSH="ssh -i ~/.ssh/borg_admin"
REPO=ssh://[email protected]_domain/srv/borg/web01
borg list "$REPO"
borg prune --dry-run --list --glob-archives 'web01-*' \
--keep-daily 7 --keep-weekly 4 --keep-monthly 12 "$REPO"
Before deleting anything, look at the archive list. If archives you expected are missing, or unexpected deletions appear, the client may have been compromised; investigate before pruning, because the append-only log is still able to roll back such changes.
When the list looks right, prune and then compact. In Borg 1.2, prune only marks data as deleted, and compact actually frees the disk space:
borg prune --list --glob-archives 'web01-*' \
--keep-daily 7 --keep-weekly 4 --keep-monthly 12 "$REPO"
borg compact "$REPO"
Run this weekly or monthly, depending on how fast the repository grows. Check free space on the backup server with df -h /srv/borg.
Step 9 - Verifying and restoring
Check the repository's consistency from the client. A plain check is safe with the append-only key because it does not modify anything:
borg check --show-rc
terminating with success status, rc 0
Once a month, add --verify-data to decrypt and verify every chunk. It reads the whole repository, so schedule it outside peak hours.
To restore, list the archives and pick one:
borg list
web01-2026-09-24T02:14:51 Thu, 2026-09-24 02:14:52 [3a9f...]
web01-2026-09-25T02:08:03 Fri, 2026-09-25 02:08:04 [8c21...]
borg extract writes into the current directory, so always change to an empty directory first. Restore /etc/nginx from the latest archive and compare it with the live copy (paths inside archives have no leading slash):
mkdir -p /tmp/restore && cd /tmp/restore
borg extract --list ::web01-2026-09-25T02:08:03 etc/nginx
diff -r /etc/nginx /tmp/restore/etc/nginx && echo "identical"
identical
For a full recovery, install Borg on a fresh server, recreate /etc/borg/borg.env, the passphrase file and an authorized SSH key, then extract the archive from /.
Troubleshooting
Failed to create/acquire the lock. A previous run was interrupted. Confirm no Borg process is running on either machine (pgrep -a borg), then run borg break-lock.
Repository path not allowed. The path in BORG_REPO does not match --restrict-to-path in authorized_keys. Both must point to the same directory.
Remote: Borg ... incompatible or protocol errors. The client and server run different major versions. Install the same Borg release on both.
The job fails with Connection closed by remote host. Test the key by hand with ssh -i /root/.ssh/borg_ed25519 [email protected]_domain; you should get no shell, only a Borg error about unexpected input. If SSH itself fails, check the key line in authorized_keys and the server's /var/log/auth.log.
Conclusion
Your server now sends encrypted, deduplicated and compressed backups every night to a remote repository it can add to but never erase, retention is applied only from a trusted machine, and you have verified that files can be restored. As next steps, add an OnFailure= handler to alert you when the service fails, schedule a monthly borg check --verify-data, and consider borgmatic if you want to manage several repositories and database dumps from one YAML file.
