BorgBackup: Incremental Backups with Encryption and systemd Automation

BorgBackup: Incremental Backups with Encryption and systemd Automation

Quick answer. BorgBackup is a command-line tool for incremental backups with deduplication and client-side encryption. Install it via apt/pip, repository initialization takes one command, automation runs through a systemd-timer. The first archive weighs as much as a full backup, every subsequent one stores only the delta. Restore works through borg extract or by mounting the archive as a regular folder.

The gist, if you don't have time to read it all
Install Borg. Initialize a repository with repokey-blake2 encryption. Set up a systemd-timer for a daily borg create run. Add borg prune with a retention policy of 7 daily / 4 weekly / 6 monthly. Once a month, test restore by hand, don’t trust logs blindly.

1. Diagnosis: why rsync and tar don’t cut it anymore

You set up a production server. You configured cron with tar running once a night. Six months later the backup disk is full, because every night a full archive gets written from scratch. Sounds familiar? Most people run this way until the first time they actually need the backup and there’s no space left for it.

BorgBackup solves this with chunk-level deduplication: it splits files into variable-sized blocks, hashes them, and stores only the unique pieces. An incremental BorgBackup run, after the first full archive, takes up only as much space as actually changed, not a byte more.

What you’ll get by the end of this article:

  • A working encrypted Borg repository
  • Automatic execution via systemd-timer instead of cron
  • A rotation policy that doesn’t eat your disk forever
  • A tested restore procedure, not just blind faith

Setup time: 30-40 minutes, if you don’t get stuck in the docs. You’ll need SSH access with write permission to the target directory and a current python3 version.

System requirements

Component Minimum version Recommended
OS Debian 11 / Ubuntu 20.04 Debian 12 / Ubuntu 24.04
Python 3.9 3.10+
BorgBackup 1.2.x 1.4.x (stable branch)
OpenSSH (for remote repository) 7.x 9.x

At the time of publication, the current stable version is 1.4.5 (1.4 branch). The 2.0 branch is still in beta, don’t put it into production, no matter how tempting the new features look. Before installing, check the latest releases on the official borgbackup.org site.

2. Reasons to move from tar/rsync to Borg

Reason Why it breaks your workflow without Borg
Full copies every night The backup disk grows linearly, and space runs out suddenly, at the worst possible moment
No encryption out of the box tar + remote storage means your data sits in plain text on someone else’s server
No integrity checking A corrupted backup only shows up at restore time, that is, when it’s already too late
Manual rotation via find -mtime One wrong flag and the script deletes the wrong thing
No deduplication across machines Backing up 10 identical servers means paying for 10x the space instead of a reasonable amount
Slow single-file restore You have to unpack the whole archive just to pull out one config file

Small mistakes cause big damage. One line in crontab without a comment, and the whole team spends two hours figuring out which script is wiping week-old backups.

3. The recipe: installing and configuring Borg

Preparation

Check your Python version and your package manager. Borg doesn’t pull in many dependencies, but it’s worth checking in advance.


python3 --version
apt list --installed 2>/dev/null | grep -i openssh

Expected result: python3 version 3.9 or higher, ssh client installed.

Step 1. Installing BorgBackup

Two paths: the system package or pip. The system package is simpler, pip gives you a more current version.


sudo apt update
sudo apt install -y borgbackup
borg --version

If the repository version is old, install via pipx, don’t mix the system python with third-party packages:


sudo apt install -y pipx
pipx install borgbackup
pipx ensurepath

Expected result: borg --version shows 1.4.x.

Step 2. Creating a backup user

Don’t run Borg as root without a reason. A dedicated user limits the damage if something goes wrong.


sudo useradd -m -s /bin/bash backupuser
sudo mkdir -p /srv/borg-repo
sudo chown backupuser:backupuser /srv/borg-repo

Expected result: the backupuser account exists, the repository directory is ready and owned by it.

Step 3. Initializing the repository

This is where it matters not to mix up the encryption mode. For a local or network disk, use repokey-blake2, the key is stored inside the repository, and blake2 is faster on modern CPUs.


sudo -u backupuser borg init --encryption=repokey-blake2 /srv/borg-repo

Borg will ask for a passphrase for the key. Pick a strong one and save it in a password manager, without it you can’t restore the backup, even with full access to the repository.

About encryption keys
If you use repokey, the key is stored inside the repository and travels with it when you copy it. If you use keyfile, the key lives only on the local machine in ~/.config/borg/keys. Lose the keyfile and the passphrase at the same time, and the data cannot be recovered, even with full access to the storage server.

borg key export /srv/borg-repo /root/borg-key-backup.txt

Expected result: a borg-key-backup.txt file with a backup copy of the key. Keep it separate from the repository, on a flash drive, in a safe, anywhere except next to the backups.

Step 4. First backup


export BORG_PASSPHRASE='your-key-passphrase'
sudo -u backupuser -E borg create --stats --progress \
  /srv/borg-repo::backup-{now:%Y-%m-%d_%H-%M} \
  /etc /home /var/www

Expected result: an archive named something like backup-2026-07-28_14-30 inside the repository. The –stats flag shows the data volume and deduplication ratio right after completion.

Next come the architectural details. Below is a diagram of how Borg works internally, so you don’t have to guess what happens between the command and the repository.

%%{init: {
  'theme': 'base',
  'themeVariables': {
    'primaryColor': '#f8fafc',
    'primaryTextColor': '#1e293b',
    'primaryBorderColor': '#94a3b8',
    'lineColor': '#64748b',
    'fontSize': '15px',
    'fontFamily': 'ui-sans-serif, system-ui, sans-serif'
  },
  'flowchart': {'curve': 'linear', 'nodeSpacing': 50, 'rankSpacing': 50}
}}%%
flowchart TD
    A["Files on the server"] --> B["Split into chunks"]
    B --> C["Hash the chunks"]
    C --> D{"Chunk already exists?"}
    D -->|Yes| E["Skip, link to existing"]
    D -->|No| F["Compress and encrypt chunk"]
    F --> G["Write to repository"]
    E --> H["Archive ready"]
    G --> H
    style A fill:#f8fafc,stroke:#3b82f6,stroke-width:2px,color:#1e40af
    style H fill:#f8fafc,stroke:#22c55e,stroke-width:2px,color:#15803d
    style D fill:#f8fafc,stroke:#f97316,stroke-width:2px,color:#9a3412

Step 5. Archive rotation via borg prune

Without rotation the repository grows forever. borg prune removes old archives according to a retention policy, keeping N most recent daily, M weekly, and so on.


sudo -u backupuser -E borg prune -v --list \
  --keep-daily=7 --keep-weekly=4 --keep-monthly=6 \
  /srv/borg-repo

Expected result: the repository contains no more than 7 daily, 4 weekly, and 6 monthly archives. Disk space is physically freed only after a separate compact command.


sudo -u backupuser -E borg compact /srv/borg-repo

Without compact, deleted chunks still take up disk space, Borg just stops referencing them. Forget about compact, and you’ll be puzzled why space didn’t free up after prune. This has happened more than once.

Step 6. Automation via systemd-timer

Cron works, but systemd-timer gives you logs through journalctl, dependencies on other units, and sane error handling. This is where the whole automation comes together.


sudo mkdir -p /etc/borg
sudo tee /etc/borg/backup.sh > /dev/null << 'EOF'
#!/bin/bash
set -euo pipefail

export BORG_REPO=/srv/borg-repo
export BORG_PASSPHRASE='your-key-passphrase'

borg create --stats --compression zstd,6 \
  "$BORG_REPO"::backup-{now:%Y-%m-%d_%H-%M} \
  /etc /home /var/www

borg prune -v --list \
  --keep-daily=7 --keep-weekly=4 --keep-monthly=6 \
  "$BORG_REPO"

borg compact "$BORG_REPO"
EOF
sudo chmod 750 /etc/borg/backup.sh
sudo chown backupuser:backupuser /etc/borg/backup.sh

The systemd service file:


[Unit]
Description=BorgBackup daily job
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=backupuser
ExecStart=/etc/borg/backup.sh
Nice=19
IOSchedulingClass=idle

Save as /etc/systemd/system/borg-backup.service.

The timer file:


[Unit]
Description=Run BorgBackup daily at 03:15

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Save as /etc/systemd/system/borg-backup.timer.


sudo systemctl daemon-reload
sudo systemctl enable --now borg-backup.timer

Expected result: systemctl list-timers shows borg-backup.timer with the next run time.

4. Verification: does the backup actually work

The command finishing without errors means nothing on its own. Verify the fact.


sudo systemctl status borg-backup.timer
sudo journalctl -u borg-backup.service --since "2 days ago"
sudo -u backupuser borg list /srv/borg-repo

Expected result: the list shows archives with the expected dates, journalctl shows no tracebacks or access errors.

Checking repository integrity is a separate command, don’t rely only on the archive list:


sudo -u backupuser -E borg check --verify-data /srv/borg-repo

–verify-data runs a check across all data, not just metadata. It takes longer, but it’s the one that catches corrupted chunks on disk.

Verifying restore: mounting an archive

This is the most important part. Borg can mount an archive as a regular FUSE filesystem, so you can browse the contents without a full extraction.


sudo apt install -y borgbackup-fuse
mkdir -p /tmp/borg-mount
sudo -u backupuser -E borg mount /srv/borg-repo::backup-2026-07-28_03-15 /tmp/borg-mount
ls /tmp/borg-mount/etc
sudo -u backupuser borg umount /tmp/borg-mount

Expected result: the contents of /etc from the archive are available as a regular folder. Forget to unmount, and the borg process will hang around in memory, don’t worry, that’s normal until umount.

5. Complications

Error Cause Fix Command
Repository has been created with encryption mode X The passphrase in BORG_PASSPHRASE doesn’t match the one used when the repository was created Check where the variable comes from, via export or from another script borg info /srv/borg-repo
Failed to lock repository A previous borg process hung or crashed without releasing the lock Remove the stale lock only if you’re sure the process is dead borg break-lock /srv/borg-repo
Insufficient free space to complete transaction The repository disk is full, prune and compact haven’t run in a while Run prune and compact manually, check free space df -h /srv/borg-repo && borg compact /srv/borg-repo
Connection closed by remote host (SSH repository) SSH session drops during a long backup, timeout on the server side Add ServerAliveInterval to the client’s SSH config ServerAliveInterval 60
borg-backup.service failed with code 1, journalctl is empty The script runs as the wrong user, no permissions on the repository Check the owner of the repository directory and User= in the unit stat /srv/borg-repo

Some backups just refuse to cooperate. That’s exactly what a hung borg check looks like on a multi-terabyte repository without –verify-data. Don’t panic, just wait or run it in screen/tmux.

6. Alternatives

  • Restic: a similar deduplication model, written in Go, builds more easily as a static binary, but has a less flexible key storage system. Pick Borg over Restic when fine-tuned compression and compatibility with borgmatic matter.
  • Rsnapshot: hardlinks instead of chunk-level deduplication, simpler conceptually, but has no built-in encryption and handles binary changes inside large files worse.
  • Duplicity: encryption via GPG, works with cloud backends out of the box, but incremental chains break if one link is lost, forcing a full backup redo.
  • Kopia: a newer tool with an architecture similar to Borg, has a web UI, but its ecosystem and documentation are younger, with fewer proven production scenarios.
  • Borgmatic: not an alternative, but a wrapper around Borg. Gives you a YAML config instead of a bash script and built-in database hooks. If the bash script from step 6 feels clunky, look into this.

The main case for Borg: project maturity, chunk-level deduplication, and client-side encryption without extra tooling.

7. Prevention

Monitoring

Without monitoring, you find out about a broken backup only at restore time. The simplest option is an HTTP healthcheck ping after every run:


curl -fsS -m 10 --retry 3 https://hc-ping.com/your-uuid-here

Add this line to the end of backup.sh after a successful prune, the healthcheck service will alert you if the ping doesn’t arrive on time.

Backing up the key itself

What to back up How often Where to store it
Encryption key (borg key export) Every time the passphrase changes Separate from the repository: cloud storage, physical media
Repository config file Once a month Together with the key
The repository itself Daily (via step 6) Separate physical disk or another server

Restoring the key if lost:


borg key import /srv/borg-repo /root/borg-key-backup.txt

Updating Borg

Before a major version upgrade, read the changelog. Between 1.2 and 1.4, some internal structure formats changed.


borg --version
pipx upgrade borgbackup
borg check --verify-data /srv/borg-repo

Rolling back: pipx lets you install a specific version if the new one misbehaves.


pipx install --force "borgbackup==1.2.9"

Security

  • UFW: allow SSH only from trusted IPs if the repository sits on a remote server
  • Fail2ban: on the SSH service accepting backup connections
  • A dedicated SSH key for backupuser, restricted via authorized_keys command=
  • Firewall IP restriction for the SSH port of the remote repository
  • Append-only mode for borg serve on the server side: protects against client compromise

Append-only is enabled with a flag when starting the remote borg serve:


borg serve --restrict-to-path /srv/borg-repo --append-only

With append-only, even if an attacker gets access to the client, they can’t delete old archives, only add new ones.

8. FAQ

Why doesn’t borg create work after setting up a systemd-timer?

Most often it’s the BORG_PASSPHRASE variable, it doesn’t carry over into a systemd service the same way it does in an interactive shell. Set it directly inside backup.sh or via Environment= in the unit, not through ~/.bashrc.

How do you verify that a BorgBackup backup actually works?

Three levels of checking: borg list shows the archives, borg check –verify-data checks data integrity on disk, and an actual restore via borg mount or borg extract to a test machine is the only way to confirm the data is readable, not just present.

What if borg prune doesn’t free up disk space?

prune only marks data as deleted in the repository metadata. Physical space reclamation happens through the separate borg compact command, without it the disk won’t clear up no matter how many archives you delete.

How does BorgBackup differ from restic?

Both tools use a similar chunk-based deduplication model. Borg offers more flexible compression settings and key storage modes, restic is simpler to deploy as a single static binary and supports more cloud backends out of the box without extra tooling like rclone.

Can you restore a single file without extracting the whole archive?

Yes, borg mount lets you mount the archive as a regular directory and copy the needed file directly from there. Alternatively, borg extract with a specific path inside the archive extracts only that file.

9. Outlook

You’ve set up a repository with client-side encryption, automation via systemd-timer instead of cron, and rotation that keeps the disk from filling up. Now the backup runs every night on its own, without reminders, and disk space usage matches exactly what changed in the last 24 hours.

From here it’s a matter of discipline: once a month, log in and verify restore by hand, don’t trust a green systemctl status alone. If something doesn’t work after setup, leave a comment, we’ll figure it out.

Architecture: client-server over SSH

%%{init: {
  'theme': 'base',
  'themeVariables': {
    'primaryColor': '#f8fafc',
    'primaryTextColor': '#1e293b',
    'primaryBorderColor': '#94a3b8',
    'noteBkgColor': '#fefce8',
    'noteTextColor': '#713f12',
    'noteBorderColor': '#fbbf24',
    'actorBkg': '#f8fafc',
    'actorBorder': '#94a3b8',
    'actorTextColor': '#1e293b',
    'fontSize': '15px',
    'fontFamily': 'ui-sans-serif, system-ui, sans-serif'
  },
  'sequence': {
    'mirrorActors': false,
    'messageAlign': 'center',
    'actorMargin': 120,
    'width': 160,
    'noteMargin': 12
  }
}}%%
sequenceDiagram
    participant T as systemd-timer
    participant C as borg client
    participant S as borg serve
    T->>C: Launch backup.sh
    C->>S: SSH connection
    S->>C: List of known chunks
    C->>S: Send new chunks
    S->>C: Write confirmation
    C->>T: Exit code 0

Port table

Port Protocol Purpose Exposed externally?
22 TCP/SSH Transport for borg serve on a remote repository Only from trusted IPs
No dedicated port – A local repository works without a network No

Useful links:

Оставайтесь на связи

Рецепты от IT-боли. Без воды, без рекламы, без маркетинговой шелухи.

Подписаться на IT-Аптеку →
Поделитесь:

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top