Automating BorgBackup Compaction for Append-Only Repos in BorgWarehouse
A practical script to automate borg compact across all BorgWarehouse repositories in append-only mode - handling quota mismatches, lock contention, and monitoring, all without touching the append-only flag.
Over the years I’ve used quite a large number of backup solutions - some exclusively enterprise, some enterprise-but-open-core, some entirely community-driven. As with most things in tech, they come and go. But one tool that has stuck around for the past 8β10 years and consistently delivered when it mattered is BorgBackup.
Borg has been around for over 15 years. It’s battle-tested across a surprisingly wide range of environments, from enterprise infrastructure to home labs run by technically-inclined individuals. That said, it does have a steeper-than-average setup curve - it’s CLI only, requires the borg binary installed on the server side, and relies on SSH to push backups to the remote repository. There’s no “just point it at an S3 bucket” option out of the box.
The good news is that over the past 5β7 years, a number of tools have emerged in the ecosystem that significantly reduce that operational overhead. I’ll have an article covering those soon, but that’s a broader topic. Today I want to focus on a specific, gnarly edge case: compacting Borg repositories when running in append-only mode.
Background: BorgWarehouse and append-only mode
BorgWarehouse is a self-hosted, web-based interface for managing a central BorgBackup server. It considerably simplifies the administration of multi-client Borg setups: user management, SSH key provisioning, per-repository storage quotas, and a clean visual dashboard for backup health - all without touching the CLI. For anyone running Borg at scale, it’s a game-changer.
One of BorgWarehouse’s most useful features is append-only mode. When enabled for a repository, the underlying Borg storage becomes immutable from the client’s perspective. Even if a client machine is fully compromised - say, by ransomware - it can only add new backup data. It cannot delete or overwrite existing historical archives.
This is the right security posture for any backup worth keeping.
The compaction problem
Append-only mode, however, creates a maintenance challenge that isn’t always obvious until you’re staring at a storage dashboard that keeps climbing.
Here’s what happens: when a Borg client runs borg prune, it marks old archives for deletion. The archive entries are removed from the index, and the underlying data segments are flagged as reclaimable. But the actual disk space is only reclaimed when borg compact runs - and borg compact must run server-side, because in append-only mode the client is explicitly blocked from making structural changes to the repository.
So the sequence in normal (non-append-only) operation is:
- Client:
borg pruneβ marks old archives for deletion - Client:
borg compactβ frees the disk space
In append-only mode, step 2 never happens from the client. The pruned data is flagged but stays on disk. Over time, your repository grows indefinitely, regardless of your retention policy.
You can work around this manually: disable append-only in BorgWarehouse for each repository, SSH in, run borg compact, re-enable append-only. It works, but it’s tedious and error-prone, and if you have dozens of repositories it becomes a real maintenance burden. More importantly, toggling off append-only mode - even briefly - defeats the purpose of having it.
The clean solution is to run borg compact server-side without touching the append-only flag at all.
The quota wrinkle
There’s one more nuance worth understanding before looking at the script.
BorgWarehouse enforces storage quotas at the SSH layer, via borg serve --storage-quota. This controls how much new data a client can push. But borg compact - which runs server-side, not through the SSH tunnel - enforces the repository’s own internal storage_quota config key, stored directly in the repository config file.
These two quota mechanisms are separate. When a repository has been accumulating unreclaimable segments over time (exactly our problem), the actual disk usage will often exceed the configured quota. And when that happens, borg compact refuses to run - it sees the quota exceeded and bails.
So the script has to handle this: check the actual disk usage against the configured quota, temporarily raise the quota if needed, run compact, then restore the original quota. The original quota is always restored, so nothing changes from BorgWarehouse’s perspective.
The script
The script is available at guiand888/borg-compact.
It’s a single Bash script that you run on the Docker host where BorgWarehouse is running. It connects to the BorgWarehouse container via docker exec, discovers all repositories, and processes each one.
What it does
For each repository, the flow is:
- Read the current
storage_quotafrom the repository config - Check actual disk usage with
du -sb - If usage exceeds quota, temporarily set a higher quota (usage + a configurable
EXTRA_QUOTA_Gheadroom) - Run
borg compact --verbose - Restore the original quota
- Send a per-repository healthcheck ping to UptimeKuma
If any step encounters a lock (another Borg operation in progress), the script retries up to RETRY_MAX_ATTEMPTS times, waiting RETRY_INTERVAL_SECONDS between attempts, before marking the repository as skipped. Non-lock errors are surfaced as failures.
At the end, a final healthcheck ping reports either finish or fail for the overall run.
Configuration
All parameters are environment variables with sensible defaults:
# Required
export UPTIMEKUMA_DOMAIN=uptime.example.com
export HEALTHCHECK_ID=your-push-id
# Optional (shown with defaults)
export CONTAINER_NAME=borgwarehouse-borgwarehouse-1
export REPO_DIR=/home/borgwarehouse/repos
export EXTRA_QUOTA_G=10
export RETRY_INTERVAL_SECONDS=300
export RETRY_MAX_ATTEMPTS=5
export VERBOSE=false
Usage
# Install
chmod +x borg_compact.sh
# Normal run
./borg_compact.sh
# Dry-run: lists repos and planned actions, no changes made
./borg_compact.sh --dry-run
# Force verbose output (useful when running interactively)
VERBOSE=true ./borg_compact.sh
# Via cron (verbosity auto-detects: quiet when not in a terminal)
0 3 * * * /opt/scripts/borg_compact.sh
Verbosity is auto-detected: if the script is attached to a terminal, it outputs verbosely; in cron or any non-interactive context, it runs quietly. You can override this with VERBOSE=true/false.
The dry-run mode (--dry-run or -n) is useful for a first pass. It will list every repository and describe exactly what action it would take - whether it would compact as-is, or temporarily expand the quota first - without making any changes and without sending healthcheck pings.
Monitoring with UptimeKuma
The script integrates with UptimeKuma’s push-based monitors. You set up a Push monitor in UptimeKuma and configure its ID in HEALTHCHECK_ID.
The script sends three levels of pings:
| Event | Status | Message |
|---|---|---|
| Script start | up |
OK |
| Per-repo success | up |
<repo-id> |
| Per-repo failure | down |
<repo-id> |
| Per-repo locked | down |
<repo-id>-locked |
| All repos done | up |
finish |
| One or more failures | down |
fail |
This gives you per-repository visibility in UptimeKuma. If a specific repo is consistently locking or failing, you’ll see it immediately without having to dig through logs.
Cron setup
Running this daily at a quiet hour is the typical deployment:
# Standard cron has no "last day of month" field, so we restrict to days 28β31
# and check whether tomorrow is the 1st - which means today is the last day.
0 3 28-31 * * [ "$(date -d '+1 day' +\%d)" = "01" ] && /opt/scripts/borg_compact.sh
If you want structured logging and output capture for cron jobs, the companion script guiand888/cron-wrapper pairs well with this.
β οΈ Warning: Automating compaction via cron requires careful thought about your threat model. Append-only mode prevents a compromised client from freeing disk space, but it does not prevent it from running
borg pruneand marking archives for deletion. If your cron blindly runsborg compactshortly after, it will permanently free that space - completing the work the attacker couldn’t do directly. The protection append-only gives you is a time window, not an absolute guarantee. Make sure your compaction schedule leaves enough of a gap (days or weeks, depending on your retention policy) to detect a compromise and intervene before the pruned data is gone for good.
Closing thoughts
The append-only flag in BorgWarehouse is one of those features you should enable without thinking twice - the protection it offers against compromised clients is worth it. But it does shift responsibility for compaction to the server operator. This script handles that shift cleanly: no manual SSH sessions, no toggling append-only on and off, no silent storage bloat.
It’s been running in my setup for a while and has handled edge cases I didn’t anticipate - like repositories that are mid-backup when compaction runs, which manifest as lock contention. The retry logic handles those gracefully.
The script and its README are at guiand888/borg-compact. Pull requests are welcome.