I Tried Setting Up a Daily Linux Backup Environment with borgbackup + a USB SSD

I Tried Setting Up a Daily Linux Backup Environment with borgbackup + a USB SSD

I set up daily backups on Fedora Linux with borgbackup and a USB SSD, using deduplication, compression and encryption. The first run shrank 7.6GB (about 200,000 files) by 55% to 3.42GB; later runs reuse existing chunks.
2026.07.20

This page has been translated by machine translation. View original

Introduction

As I delegate more development work to AI agents, the risk of unintended file deletion or overwriting has also grown. I manage the main deliverables with Git, but putting everything under the home directory under Git management is not realistic. On macOS I handled this with Time Machine and Google Drive, but since I started using a Linux development environment, I needed an equivalent backup setup.

borgbackup is a tool that provides variable-size chunk-based deduplication, compression, encryption, and generation management, and can store incremental backups efficiently.

https://borgbackup.readthedocs.io/

In this article, I built the whole setup: preparing a USB SSD, initializing a borg repository, creating a backup script, and running it daily with a systemd user timer. I also checked the measured results of the first backup and restore patterns.

What I Tested

Test Environment

Item Details
OS Fedora Linux 43 (Asahi Remix / aarch64)
borg 1.4.4 (dnf)
USB SSD 500GB
Backup target User's home directory (about 7.6GB, about 200,000 files)

Preparing the USB SSD

First, check the device name of the connected USB SSD. Look at the capacity and the connection bus (the TRAN column) to identify that the target is the intended USB SSD.

$ lsblk -o NAME,SIZE,TYPE,MODEL,TRAN
NAME          SIZE TYPE MODEL             TRAN
sda         465.8G disk SSD-PSTA/D        usb

SSD-PSTA/D appears as a 465.8GB disk connected via the usb bus. Create a GPT label and an ext4 partition on this device, and format it.

$ sudo parted /dev/sda --script mklabel gpt
$ sudo parted /dev/sda --script mkpart primary ext4 0% 100%
$ sudo mkfs.ext4 -L borgbackup /dev/sda1

After formatting, register it in fstab so that it is mounted at /mnt/backup.

$ sudo mkdir -p /mnt/backup
$ sudo blkid /dev/sda1

Add an entry to fstab using the obtained UUID.

# /etc/fstab
UUID=<your-uuid>  /mnt/backup  ext4  defaults,nofail,x-systemd.device-timeout=5s  0  2

With nofail, boot continues without treating a mount failure as an error even if the device does not exist. x-systemd.device-timeout=5s shortens the timeout for waiting for the device, preventing boot delays when it is not connected.

$ sudo mount /mnt/backup
$ sudo chown $USER:$USER /mnt/backup

Installing borgbackup

borgbackup can be installed with dnf from Fedora's official repositories.

$ sudo dnf install -y borgbackup
$ borg --version
borg 1.4.4

Initializing the borg Repository

Generate a passphrase to use for encryption. Here I used Python's secrets module.

$ python3 -c "import secrets; print(secrets.token_urlsafe(24))"

Pass the generated passphrase through an environment variable and initialize the repository. I specified repokey-blake2 as the encryption mode. It combines AES-CTR-256 encryption with BLAKE2b-256 for authentication and chunk ID generation. For local backups, the repokey mode, which makes key management simple, is suitable.

$ export BORG_PASSPHRASE='Abc123xYz-your-passphrase-here_'
$ borg init --encryption=repokey-blake2 /mnt/backup/borg

Once initialization is complete, remove the environment variable with unset BORG_PASSPHRASE. In regular operation, the passphrase is passed via the systemd service's EnvironmentFile.

With the repokey mode, the encryption key is stored inside the repository. Export the key in case the SSD is lost or fails.

$ borg key export /mnt/backup/borg /path/to/borg-key-backup.txt

Backup Script

I adopted zstd,3 as the compression method for its good balance of speed and compression ratio. Directories that can be regenerated in the development environment (caches, dependency packages, build artifacts, etc.) are excluded to keep the backup size and duration down. For generation management, I keep 7 daily, 4 weekly, and 3 monthly backups, so that the last week can be restored on a daily basis, while older ones are thinned out gradually into weekly and monthly ones. The actual retention period varies depending on the backup frequency and missing days.

#!/bin/bash
# ~/tool/backup/backup.sh
set -euo pipefail

REPO="/mnt/backup/borg"
SOURCE="$HOME"
export BORG_PASSPHRASE="${BORG_PASSPHRASE:?Set BORG_PASSPHRASE before running}"
export BORG_REPO="$REPO"

ARCHIVE_NAME="ws-{now:%Y-%m-%dT%H:%M:%S}"
LOG_FILE="$HOME/tool/backup/backup.log"

log() {
    echo "[$(date -Iseconds)] $*" | tee -a "$LOG_FILE"
}

# 前提チェック
if ! mountpoint -q /mnt/backup; then
    log "SKIP: /mnt/backup is not mounted."
    exit 0
fi

# バックアップ実行
log "Starting backup: $SOURCE -> $REPO"

# すべてのリポジトリがリモートにpush済みなら下記を有効化し .git/objects/pack を除外できます
#    --exclude "sh:$SOURCE/**/.git/objects/pack"

borg create                             \
    --verbose                           \
    --filter AME                        \
    --stats                             \
    --show-rc                           \
    --compression zstd,3                \
    --exclude-caches                    \
    --exclude "sh:$SOURCE/.cache"          \
    --exclude "sh:$SOURCE/**/node_modules" \
    --exclude "sh:$SOURCE/**/__pycache__"  \
    --exclude "sh:$SOURCE/**/.venv"        \
    --exclude "sh:$SOURCE/**/.tox"         \
    --exclude "sh:$SOURCE/**/target"       \
    --exclude "sh:$SOURCE/**/.terraform"   \
    --exclude "sh:$SOURCE/**/dist"         \
    ::${ARCHIVE_NAME}                   \
    "$SOURCE"                           \
    2>&1 | tee -a "$LOG_FILE"

# Prune(世代管理)
log "Pruning old archives..."
borg prune                              \
    --list                              \
    --show-rc                           \
    --keep-daily    7                   \
    --keep-weekly   4                   \
    --keep-monthly  3                   \
    2>&1 | tee -a "$LOG_FILE"

# コンパクション(回収対象セグメントがあれば領域を回収)
borg compact 2>&1 | tee -a "$LOG_FILE"

log "Backup finished."
borg info 2>&1 | tee -a "$LOG_FILE"

Thanks to the mountpoint -q check at the beginning of the script, if the USB SSD is not connected, the script exits normally without running the backup. The passphrase is assumed to be set in advance as an environment variable with export BORG_PASSPHRASE='...' before running the script. Copy and paste it from the password manager.

Daily Execution with a systemd user timer

I use a systemd user timer for scheduled execution. First is the service definition. With ConditionPathIsMountPoint, the start condition is not met and the run is skipped if the SSD is not connected. Nice and IOSchedulingClass=idle keep the backup process at low priority to reduce the impact on other work.

# ~/.config/systemd/user/ws-backup.service
[Unit]
Description=Borg backup home directory to USB SSD
ConditionPathIsMountPoint=/mnt/backup

[Service]
Type=oneshot
ExecStart=%h/tool/backup/backup.sh
EnvironmentFile=%h/tool/backup/.env
Nice=10
IOSchedulingClass=idle

EnvironmentFile loads a file containing the passphrase. Set the value copied from the password manager in the file.

# ~/tool/backup/.env (chmod 600)
BORG_PASSPHRASE=Abc123xYz-your-passphrase-here_

Next is the timer definition. It starts every day at 03:00, and RandomizedDelaySec=300 adds a random delay of up to 5 minutes. With Persistent=true, if the machine was off at the scheduled start time, the run is executed late at the next startup.

# ~/.config/systemd/user/ws-backup.timer
[Unit]
Description=Daily borg backup timer

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

[Install]
WantedBy=timers.target

Enable the timer and check its status.

$ systemctl --user daemon-reload
$ systemctl --user enable --now ws-backup.timer
$ systemctl --user status ws-backup.timer
● ws-backup.timer - Daily borg backup timer
     Active: active (waiting)
    Trigger: Mon 2026-07-21 03:02:22 JST; 23h left

It became Active: active (waiting), and the next trigger was set for 03:02 the next day (including the random delay).

Note that this configuration assumes desktop use where the timer runs during a login session. In a server environment where a user session does not persist, such as with SSH connections only, run loginctl enable-linger <user>. This lets user services run even when the user is not logged in.

Running the First Backup and Results

I ran the script manually and took the first backup.

$ ~/tool/backup/backup.sh

This is the output of borg create --stats.

Archive name: ws-2026-07-20T00:22:52
Duration: 2 minutes 18.09 seconds
Number of files: 204,884
                       Original size      Compressed size    Deduplicated size
This archive:                7.58 GB              5.75 GB              3.42 GB

About 200,000 files with an original size of 7.58GB shrank to 5.75GB with zstd,3 compression, and further to 3.42GB with deduplication (about a 55% reduction), and it took 2 minutes 18 seconds.

Results of the Second (Automatic Daily) Run

The second backup was taken by the automatic run from the systemd timer.

Archive name: ws-2026-07-20T03:02:56
Duration: 40.07 seconds
Number of files: 204,922
                       Original size      Compressed size    Deduplicated size
This archive:                7.58 GB              5.75 GB            127.80 kB
All archives:               15.16 GB             11.51 GB              3.42 GB

The difference after deduplication was only 127.80 kB, and it took 40 seconds. If there are no major changes since the previous run, reusing existing chunks keeps the amount of data newly added to the repository small.

Checking and Using Archives

Check the repository information.

$ borg info /mnt/backup/borg
Repository ID: 2e7cb78e...
Encrypted: Yes (repokey BLAKE2b)
                       Original size      Compressed size    Deduplicated size
All archives:               15.16 GB             11.51 GB              3.42 GB
                       Unique chunks         Total chunks
Chunk index:                   75812               414144

Encrypted: Yes (repokey BLAKE2b) confirms that encryption is enabled. The list of archives is obtained with borg list.

$ borg list /mnt/backup/borg
ws-2026-07-20T00:22:52               Mon, 2026-07-20 00:22:52 [fa94ad80...]
ws-2026-07-20T03:02:56               Mon, 2026-07-20 03:02:56 [c3b17e22...]

By specifying an archive name and passing a path, you can check the list of files under a specific directory.

$ borg list /mnt/backup/borg::ws-2026-07-20T00:22:52 home/<user>/projects/my-project/ | head -15
File list output
drwxrwsr-x user   user          0 Sun, 2026-07-12 04:35:14 home/<user>/projects/my-project
-rw-rw-r-- user   user        139 Sun, 2026-06-07 18:01:59 home/<user>/projects/my-project/monitor.timer
-rwxrwxr-x user   user       4543 Mon, 2026-06-15 00:49:30 home/<user>/projects/my-project/monitor.sh
-rw-rw-r-- user   user        149 Sun, 2026-06-07 18:01:59 home/<user>/projects/my-project/monitor.service
drwxrwsr-x user   user          0 Mon, 2026-07-20 00:04:47 home/<user>/projects/my-project/data
-rw-rw-r-- user   user      21533 Sun, 2026-06-07 23:56:29 home/<user>/projects/my-project/data/2026-06-07.jsonl
-rw-rw-r-- user   user      92911 Mon, 2026-06-08 23:57:46 home/<user>/projects/my-project/data/2026-06-08.jsonl
-rw-rw-r-- user   user      92903 Tue, 2026-06-09 23:58:46 home/<user>/projects/my-project/data/2026-06-09.jsonl
-rw-rw-r-- user   user      93272 Wed, 2026-06-10 23:57:46 home/<user>/projects/my-project/data/2026-06-10.jsonl
-rw-rw-r-- user   user      93272 Thu, 2026-06-11 23:56:16 home/<user>/projects/my-project/data/2026-06-11.jsonl
-rw-rw-r-- user   user      93629 Fri, 2026-06-12 23:59:16 home/<user>/projects/my-project/data/2026-06-12.jsonl
-rw-rw-r-- user   user      93272 Sat, 2026-06-13 23:57:46 home/<user>/projects/my-project/data/2026-06-13.jsonl
-rw-rw-r-- user   user      93272 Sun, 2026-06-14 23:58:16 home/<user>/projects/my-project/data/2026-06-14.jsonl
-rw-rw-r-- user   user     139081 Mon, 2026-06-15 23:57:17 home/<user>/projects/my-project/data/2026-06-15.jsonl
-rw-rw-r-- user   user     138860 Tue, 2026-06-16 23:55:17 home/<user>/projects/my-project/data/2026-06-16.jsonl

These operations require the passphrase. There are several ways to pass the passphrase to borg, and you can choose among them depending on the use case.

Method Use case Notes
BORG_PASSPHRASE (export) Interactive sessions, scripts Remains in the process environment variables
BORG_PASSCOMMAND Retrieve each time with an external command Can integrate with password managers, etc. Security depends on the command implementation and how the secret is stored

In this article, I adopted the approach of passing the value copied from the password manager to BORG_PASSPHRASE. For automatic execution from the systemd timer, it is passed to the script via the EnvironmentFile described earlier.

$ export BORG_PASSPHRASE='Abc123xYz-your-passphrase-here_'
$ borg list /mnt/backup/borg

With BORG_PASSCOMMAND, you can obtain the passphrase from the standard output of an external command. By combining it with a password manager's CLI, you can also achieve a setup that does not keep a plaintext file.

Single-File Restore Patterns

I checked three typical patterns for extracting just one file.

The first is to move to the root and restore by overwriting the original path as is. Use it when you want to immediately put an accidentally deleted file back in its original location.

$ cd /
$ borg extract /mnt/backup/borg::ws-2026-07-20T00:22:52 \
    home/<user>/projects/my-project/SPEC.md
-rw-rw-r--. 1 user user 5575  6月 15 00:50 /home/<user>/projects/my-project/SPEC.md

The second is to extract into a working directory. Since it is extracted while keeping the path structure inside the archive, it suits workflows where you want to check the differences against the current file before applying it.

$ mkdir -p /tmp/borg-restore
$ cd /tmp/borg-restore
$ borg extract /mnt/backup/borg::ws-2026-07-20T00:22:52 \
    home/<user>/projects/my-project/SPEC.md

The extraction destination is /tmp/borg-restore/home/<user>/projects/my-project/SPEC.md.

The third is to strip the leading path levels with --strip-components and extract the file flat. It is convenient when you do not need the directory structure and just want the single file.

$ mkdir -p /tmp/borg-restore-flat
$ cd /tmp/borg-restore-flat
$ borg extract --strip-components 4 /mnt/backup/borg::ws-2026-07-20T00:22:52 \
    home/<user>/projects/my-project/SPEC.md

--strip-components 4 removes the four levels of home/<user>/projects/my-project/. The extraction destination is /tmp/borg-restore-flat/SPEC.md.

Here is how to choose among the three patterns.

Pattern Command Destination Use case
Overwrite original path cd / && borg extract ... /home/<user>/.../file When you want to restore immediately
Separate directory cd /tmp/restore && borg extract ... /tmp/restore/home/<user>/.../file Check differences before applying
Flat extraction cd /tmp/flat && borg extract --strip-components N ... /tmp/flat/file When you only want the single file

Summary

The initial 7.58GB fit into 3.42GB after deduplication, and the second daily run completed in 40 seconds with a difference of 127 kB. The additional capacity needed from here on depends on the amount of daily changes, but a 500GB SSD leaves ample room for the foreseeable future. With the combination of borgbackup, a USB SSD, and a systemd user timer, I was able to build a daily backup environment with just a handful of configuration files.

As a side note, Amazon Linux 2023 also provides a borgbackup package via the SPAL repository. I only confirmed that the package exists this time, but it seems like it could be an option when you want to complete path-specific backups and generation management on EC2 locally.

https://dev.classmethod.jp/articles/amazon-linux-2023-spal-repository/

Share this article