I tried building a daily backup environment for Linux using BorgBackup + USB SSD

I tried building a daily backup environment for Linux using BorgBackup + USB SSD

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

This page has been translated by machine translation. View original

Introduction

As AI agents are increasingly entrusted with development work, the risk of unintended file deletions and overwrites has also grown. While major deliverables are managed with Git, tracking everything under the home directory in Git is not practical. On macOS, I had been handling this with Time Machine and Google Drive, but since I started using a Linux development environment, I needed an equivalent backup solution.

borgbackup is a tool that features 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 to initializing a borg repository, creating a backup script, and setting up daily execution via a systemd user timer. Actual results from the first backup and restore patterns are also reviewed.

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

Preparing the USB SSD

First, check the device name of the connected USB SSD. Identify the target device by checking 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.8 GB 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 add 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 the 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 via 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 as an environment variable and initialize the repository. The encryption mode repokey-blake2 was specified. This mode combines AES-CTR-256 encryption with BLAKE2b-256 for authentication and chunk ID generation. For local backups, the repokey method is suitable due to its simpler 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 regular operation, pass the passphrase via EnvironmentFile in a 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

The zstd,3 compression method was chosen for its good balance of speed and compression ratio. Directories that can be regenerated in a development environment (caches, dependency packages, build artifacts, etc.) are excluded to reduce backup size and time. Generation management retains 7 daily, 4 weekly, and 3 monthly backups, allowing daily rollback for the most recent week while gradually pruning older backups on a weekly and monthly basis. The actual retention period varies depending on backup frequency and missed 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 ensures that if the USB SSD is not connected, the backup is skipped and the script exits normally. The passphrase must be set in the environment variable with export BORG_PASSPHRASE='...' before running the script. It is passed by copying from a password manager.

Daily Execution with systemd User Timer

A systemd user timer is used for scheduled execution. First, the service definition. The ConditionPathIsMountPoint directive skips startup if the SSD is not connected. Nice and IOSchedulingClass=idle keep the backup process at low priority to reduce 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 your password manager in this file.

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

Next, the timer definition. It triggers every day at 03:00, with RandomizedDelaySec=300 adding up to 5 minutes of random delay. Persistent=true ensures that if the machine was off at the scheduled time, the backup will run on 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), with the next trigger set to 03:02 the following day (including random delay).

Note that this configuration assumes the timer runs during a desktop login session. On server environments where only SSH connections are used and no user session persists, 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 obtain 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.58 GB were compressed to 5.75 GB with zstd,3, and further reduced to 3.42 GB after deduplication (approximately 55% reduction), completing in 2 minutes and 18 seconds.

Results of the Second Run (Daily Automatic Execution)

The second backup was obtained 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 diff was only 127.80 kB, completed in 40 seconds. When there are few changes since the last run, reuse of existing chunks keeps the amount of new data added to the repository small.

Checking and Working with 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. Use borg list to get a list of archives.

$ 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 path, you can view a 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, used depending on the situation.

Method Use case Notes
BORG_PASSPHRASE (export) Interactive session, scripts Remains in process environment variables
BORG_PASSCOMMAND Obtain on demand via external command Can integrate with password managers. Security depends on command implementation and secret storage method

This article uses the method of passing a value copied from a password manager to BORG_PASSPHRASE. For automatic execution via 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

Using BORG_PASSCOMMAND, the passphrase can be obtained from the standard output of an external command. Combined with a password manager's CLI, it is also possible to achieve a configuration that does not retain any plaintext files.

Single File Restore Patterns

Three common patterns for extracting a single file were reviewed.

The first is moving to the root directory and restoring the file directly to its original path, overwriting it. This is useful when you want to immediately restore an 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 extracting to a working directory. Since the files are extracted while preserving the path structure from the archive, this approach is suitable when you want to compare with current files 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 using --strip-components to remove leading path levels and extract files flat. This is convenient when you only need 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 three patterns are used as follows:

Pattern Command Destination Use case
Overwrite to 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 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.58 GB fit into 3.42 GB after deduplication, and the second daily run completed with a 127 kB diff in 40 seconds. Future storage additions will depend on daily change volume, but a 500 GB SSD provides ample headroom for the foreseeable future. By combining borgbackup, a USB SSD, and a systemd user timer, a daily backup environment was built with just a handful of configuration files.

It is also worth noting that borgbackup is available on Amazon Linux 2023 via the SPAL repository. While this article only confirms the package's availability, it could be a viable option for managing backups and generation control of specific paths on EC2 locally.

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

Share this article