I tried building a daily Linux backup environment with borgbackup + USB SSD

I tried building a daily Linux backup environment with borgbackup + USB SSD

I built a daily backup environment on Fedora Linux using borgbackup and a USB SSD, with support for deduplication, compression, and encryption. The initial backup of 7.6 GB and approximately 200,000 files was reduced by 55% to 3.42 GB, and from the second backup onward, incremental backups are possible by reusing existing chunks.
2026.07.20

This page has been translated by machine translation. View original

Introduction

As AI agents take on more development tasks, the risk of unintended file deletion or overwriting has increased. While major deliverables are managed with Git, tracking everything under the home directory in Git is not practical. In macOS environments, Time Machine and Google Drive have served as workarounds, but after starting to use a Linux development environment, an equivalent backup solution became necessary.

borgbackup is a tool that offers variable-length chunk-based deduplication, compression, encryption, and generation management, enabling efficient storage of incremental backups.

https://borgbackup.readthedocs.io/

This article covers everything from preparing a USB SSD, initializing a borg repository, creating a backup script, to setting up daily execution via a systemd user timer. Actual measurements from the first backup and restoration patterns are also reviewed.

Verification Details

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 (approx. 7.6GB, approx. 200,000 files)

Preparing the USB SSD

First, check the device name of the connected USB SSD. Identify the target USB SSD by looking at the capacity and connection bus (TRAN column).

$ 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 ext4 partition on this device, then 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 to mount at /mnt/backup.

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

Use the obtained UUID to append an entry to fstab.

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

nofail ensures that if the device is absent, a mount failure is not treated as an error and boot continues. x-systemd.device-timeout=5s shortens the device wait timeout and prevents boot delays when the device is not connected.

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

Installing borgbackup

borgbackup can be installed from Fedora's official repository using dnf.

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

Initializing the borg Repository

Generate a passphrase for encryption. Here, Python's secrets module was used.

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

Pass the generated passphrase via an environment variable and initialize the repository. repokey-blake2 was specified as the encryption method. This method combines AES-CTR-256 encryption with BLAKE2b-256 for authentication and chunk ID generation. For local backups, the repokey method is suitable for its straightforward key management.

$ 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. For ongoing operation, the passphrase is passed via EnvironmentFile in the systemd service.

With the repokey method, the encryption key is stored inside the repository. Export the key to prepare for SSD loss or failure.

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

Backup Script

zstd,3 was chosen 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 reduce backup size and time. Generation management retains daily 7, weekly 4, and monthly 3, designed to allow daily recovery for the most recent week while gradually thinning out older backups on a weekly and monthly basis. The actual retention period varies depending on 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"
}

# Prerequisite check
if ! mountpoint -q /mnt/backup; then
    log "SKIP: /mnt/backup is not mounted."
    exit 0
fi

# Run backup
log "Starting backup: $SOURCE -> $REPO"

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"         \
    --exclude "sh:$SOURCE/**/.git/objects/pack" \
    ::${ARCHIVE_NAME}                   \
    "$SOURCE"                           \
    2>&1 | tee -a "$LOG_FILE"

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

# Compaction (reclaim space if there are reclaimable segments)
borg compact 2>&1 | tee -a "$LOG_FILE"

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

The mountpoint -q check at the beginning of the script means that if the USB SSD is not connected, the backup will not run and will exit successfully. The passphrase is expected to be set in the environment variable with export BORG_PASSPHRASE='...' before running the script. It is passed by copy-pasting from a password manager.

Daily Execution with systemd User Timer

systemd user timer is used for scheduled execution. First, the service definition. ConditionPathIsMountPoint skips startup if the SSD is not connected, as the startup condition is not met. Nice and IOSchedulingClass=idle keep the backup process at low priority, reducing the impact on other tasks.

# ~/.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 the file containing the passphrase. Set the value copied from the password manager in this file.

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

Next, the timer definition. It starts daily at 03:00 and adds a random delay of up to 5 minutes with RandomizedDelaySec=300. Persistent=true ensures that if the machine is off at the scheduled start time, it will run with a delay 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

The status shows Active: active (waiting), and the next trigger is set for 03:02 the following day (including the random delay).

Note that this configuration assumes the timer operates during a desktop login session. For server environments where only SSH connections are used and no user session is persistent, run loginctl enable-linger <user>. This allows user services to run even without a user login.

Running and Results of the First Backup

The script was run manually to take the first backup.

$ ~/tool/backup/backup.sh

Output from 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

Approximately 200,000 files totaling 7.58GB were compressed with zstd,3 to 5.75GB, further reduced to 3.42GB (approximately 55% reduction) through deduplication, in 2 minutes and 18 seconds.

Results of the Second Run (Daily Automatic Execution)

The second backup was taken via automatic execution by 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 deduplicated difference was only 127.80 kB, and it completed in 40 seconds. When there are no major changes since the last backup, the amount of new data added to the repository can be kept small by reusing existing chunks.

Checking and Using Archives

Check 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. Get a list of archives 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 view the file list 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 the method can be chosen based on the use case.

Method Use case Notes
BORG_PASSPHRASE (export) Interactive session, scripts Remains in process environment variables
BORG_PASSCOMMAND Retrieve on demand via external command Can integrate with password managers. Security depends on the command implementation and how secrets are stored

This article adopted the method of passing the value copied from a password manager to BORG_PASSPHRASE. For automatic execution via systemd timer, it is passed to the script via the EnvironmentFile mentioned earlier.

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

Using BORG_PASSCOMMAND, the passphrase can be obtained from the standard output of an external command. By combining this with a password manager's CLI, a configuration that does not retain plaintext files can be achieved.

Single File Restoration Patterns

Three representative patterns for extracting a single file were confirmed.

The first is to move to the root and restore directly by overwriting the original path. This is useful when you want to immediately restore a accidentally deleted file to 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 to a working directory. Since it is extracted while preserving the path structure within the archive, this approach is suited for operations where you want to check the diff against the current file before applying changes.

$ 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 will be /tmp/borg-restore/home/<user>/projects/my-project/SPEC.md.

The third is to use --strip-components to remove the leading directory levels and extract the file flat. This is convenient when you only want the file itself without the directory structure.

$ 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 4 levels of home/<user>/projects/my-project/. The extraction destination becomes /tmp/borg-restore-flat/SPEC.md.

The usage of the three patterns is as follows.

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 When checking diff before applying
Flat extraction cd /tmp/flat && borg extract --strip-components N ... /tmp/flat/file When you only need the file itself

Summary

The initial 7.58GB fit into 3.42GB after deduplication, and the second daily run completed with a 127 kB diff in 40 seconds. Future additional capacity will depend on the daily amount of changes, but a 500GB SSD provides ample capacity for the foreseeable operation. By combining borgbackup, a USB SSD, and a systemd user timer, a daily backup environment was built with the simplicity of just a few configuration files.

borgbackup packages are also available via the SPAL repository on Amazon Linux 2023. While this article only confirms the existence of the package, it could be a viable option for scenarios where you want to handle backups of specific paths or generation management on EC2 entirely locally.

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

Share this article