Backing up Proxmox with BorgBackup, monitored with Borg-UI

My homelab has a Proxmox VE node (host01) and a Synology NAS. I wanted an automatic, deduplicated and encrypted backup of VMs, containers and host configuration, with a web interface to browse the archives without having to remember every command.

The setup has two parts with clearly separate roles:

ComponentWhere it runsRole
Bash script + cronProxmox hostExecutor: creates the dumps, archives them with Borg, runs prune and compact
Borg-UIDedicated LXC containerControl panel: archive list, statistics, restore, checks

The Borg repository lives on the NAS, exported over NFS and mounted on the host at /mnt/nas-backup.

Why not let Borg-UI do everything? vzdump and the dump folder only exist on the host. Borg-UI runs in a container and would have to move hundreds of GB from the host to the container and back out to the NAS. A script on the host is simpler and more efficient. Borg-UI stays the place where you look at what happened.


1. The backup script

In order, the script:

  1. checks that the NAS is mounted;
  2. runs vzdump for every VM and container (snapshot mode, zstd compression);
  3. archives the dump folder into Borg;
  4. archives the Proxmox configuration into a separate archive;
  5. applies retention (prune) and frees space (compact);
  6. always, even after an error, deletes local dumps older than 3 hours;
  7. sends an email with the result.

Save it as /usr/local/bin/proxmox-borg-backup.sh:

#!/bin/bash
# ==============================================================
# proxmox-borg-backup.sh
# Full Proxmox VE backup → BorgBackup on NAS
# ==============================================================

set -euo pipefail

# ── Configuration ───────────────────────────────────────────
BORG_REPO="/mnt/nas-backup/borg-proxmox"
# The passphrase is NOT in the script: see /etc/borg-backup.env
source /etc/borg-backup.env
export BORG_RELOCATED_REPO_ACCESS_IS_OK=yes

DUMP_DIR="/var/lib/vz/dump"
LOG_FILE="/var/log/borg-backup.log"
DATE=$(date +%Y-%m-%d_%H-%M)

# Retention policy
KEEP_DAILY=7
KEEP_WEEKLY=4
KEEP_MONTHLY=6

# Email notification
EMAIL_TO="your-address@example.com"
EMAIL_FROM="sender@example.com"


# ── Functions ───────────────────────────────────────────────
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }

check_nas_mount() {
    if ! mountpoint -q /mnt/nas-backup; then
        log "NAS not mounted. Trying to mount..."
        mount /mnt/nas-backup || { log "ERROR: mount failed."; return 1; }
    fi
    log "NAS mounted."
}

backup_vms_and_ct() {
    log "=== Dumping VMs and containers ==="
    vzdump --all \
           --compress zstd \
           --mode snapshot \
           --dumpdir "$DUMP_DIR" \
           --quiet \
           2>&1 | tee -a "$LOG_FILE"
    log "Dump complete."
}

backup_pve_config() {
    log "=== Backing up Proxmox configuration ==="
    borg create \
        --stats \
        --compression zstd,3 \
        "${BORG_REPO}::pve-config-${DATE}" \
        /etc/pve \
        /etc/network/interfaces \
        /etc/hosts \
        /etc/hostname \
        2>&1 | tee -a "$LOG_FILE"
}

backup_dumps_to_borg() {
    log "=== Moving dumps into Borg ==="
    borg create \
        --stats \
        --compression zstd,3 \
        "${BORG_REPO}::proxmox-${DATE}" \
        "$DUMP_DIR" \
        --exclude "$DUMP_DIR/*.log" \
        2>&1 | tee -a "$LOG_FILE"
    log "Borg archive created: proxmox-${DATE}"
}

prune_old_backups() {
    log "=== Pruning old archives ==="
    borg prune \
        --list \
        --glob-archives 'proxmox-*' \
        --keep-daily   "$KEEP_DAILY" \
        --keep-weekly  "$KEEP_WEEKLY" \
        --keep-monthly "$KEEP_MONTHLY" \
        "$BORG_REPO" \
        2>&1 | tee -a "$LOG_FILE"

    borg prune \
        --list \
        --glob-archives 'pve-config-*' \
        --keep-daily   "$KEEP_DAILY" \
        --keep-weekly  "$KEEP_WEEKLY" \
        --keep-monthly "$KEEP_MONTHLY" \
        "$BORG_REPO" \
        2>&1 | tee -a "$LOG_FILE"

    # Since Borg 1.2, prune doesn't free space: compact does
    borg compact "$BORG_REPO" 2>&1 | tee -a "$LOG_FILE"
}

cleanup_dumps() {
    log "=== Cleaning up temporary dumps ==="
    find "$DUMP_DIR" -name "vzdump-*" -mmin +180 -delete
    log "Temporary dumps removed."
}

# ── Main ────────────────────────────────────────────────────
BACKUP_OK=true

log "======================================================"
log "Starting Proxmox → Borg backup | ${DATE}"
log "======================================================"

check_nas_mount      || BACKUP_OK=false
$BACKUP_OK && backup_vms_and_ct    || BACKUP_OK=false
$BACKUP_OK && backup_dumps_to_borg || BACKUP_OK=false
$BACKUP_OK && backup_pve_config    || BACKUP_OK=false
$BACKUP_OK && prune_old_backups    || BACKUP_OK=false

# cleanup_dumps ALWAYS runs, even if an earlier step failed,
# so local dumps don't pile up and fill /
log "=== Local dump cleanup (independent of backup result) ==="
cleanup_dumps || log "WARNING: cleanup_dumps reported an error"

if $BACKUP_OK; then
    log "Backup completed successfully!"
    printf "Subject: Proxmox backup OK - %s\nFrom: %s\nTo: %s\n\nBackup completed successfully on %s.\n\nLog details:\n%s" \
        "$DATE" "$EMAIL_FROM" "$EMAIL_TO" "$DATE" \
        "$(tail -20 "$LOG_FILE")" \
        | msmtp "$EMAIL_TO"
else
    log "ERROR: the backup did not complete."
    printf "Subject: Proxmox backup FAILED - %s\nFrom: %s\nTo: %s\n\nThe backup of %s failed.\n\nLast log lines:\n%s" \
        "$DATE" "$EMAIL_FROM" "$EMAIL_TO" "$DATE" \
        "$(tail -40 "$LOG_FILE")" \
        | msmtp "$EMAIL_TO"
    exit 1
fi

Two design choices that matter

cleanup_dumps outside the && chain. In an early version the dump cleanup was chained with && to the rest. If a backup failed, the cleanup never ran: the dumps stayed on disk, filled pve-root, and the next backup failed too. A vicious circle. Now the cleanup runs regardless of the result.

Two prefixes, two retentions. Archives are named proxmox-DATE (dumps) and pve-config-DATE (configuration). Prune uses --glob-archives to apply retention to each group separately, so a run of failed backups can’t accidentally delete archives of the other kind.


2. Keeping the passphrase out of the script

Putting BORG_PASSPHRASE in plain text in the script is convenient but bad: it ends up in backups, screenshots and every copy of the script. I move it to a file only root can read.

# create the file with restrictive permissions from the start
( umask 077; cat > /etc/borg-backup.env <<'EOF'
export BORG_PASSPHRASE="YOUR-PASSPHRASE"
EOF
)
chown root:root /etc/borg-backup.env
chmod 600 /etc/borg-backup.env

Check that it works:

bash -c 'source /etc/borg-backup.env; borg list --last 1 /mnt/nas-backup/borg-proxmox'

If the passphrase has ended up in plain text somewhere unsafe, change it. With repokey the change is instant and doesn’t rewrite the repository:

source /etc/borg-backup.env
borg key change-passphrase /mnt/nas-backup/borg-proxmox
# then update the value in /etc/borg-backup.env

Also export the repository key and keep it off the host, together with the passphrase, in your password manager. Without both the key and the passphrase the repository can’t be opened anymore:

borg key export /mnt/nas-backup/borg-proxmox /root/borg-key-backup.txt
# copy it somewhere else, then delete the local file

3. Scheduling with cron

Make the script executable and run it once by hand, watching the log:

chmod 700 /usr/local/bin/proxmox-borg-backup.sh
bash -n /usr/local/bin/proxmox-borg-backup.sh && echo "syntax ok"
/usr/local/bin/proxmox-borg-backup.sh
tail -50 /var/log/borg-backup.log

Then schedule it with crontab -e as root:

0 8 * * * /usr/local/bin/proxmox-borg-backup.sh

It runs every day at 08:00. Pick a time when the host and the NAS aren’t busy with something else. The NAS, for example, can be set to spin down its disks after a period of inactivity: the mount at the start of the script wakes it up.

To see what’s scheduled:

crontab -l

Mind the lock. Borg holds a lock on the repository while it works. Don’t run the script by hand while the cron job is running, and don’t start a check from the UI at the same time.


4. Borg-UI as a control panel

Borg-UI installs on Proxmox with the community script (community-scripts), which creates an LXC container with Docker ready to go. I put it in CT 106, with 2 cores, 4 GB of RAM and a fixed IP.

The goal: let Borg-UI see the repository that lives on the NAS and that the host writes every morning. Here are all the steps, including the obstacles I hit.

4.1 First obstacle: bind mount permissions

The container is unprivileged and has a bind mount to /mnt/nas-backup. I tried to read the repository from the container:

pct exec 106 -- ls -ln /mnt/nas-backup/borg-proxmox
# ls: cannot access '/mnt/nas-backup/borg-proxmox': Permission denied

The cause: in an unprivileged container the host’s root user (uid 0) shows up as nobody (uid 65534). The repository files belong to root with 0600 permissions, so they can’t be read from the container.

There are two ways out: remap uids with lxc.idmap (delicate, because it affects permissions on the whole mount) or run Borg on the host over SSH. I chose the second: Borg runs on the host as root and the container is only the interface.

4.2 SSH key from the container to the host

I generate a key in the container and authorize it on the host:

# in the container: install the client and generate the key
pct exec 106 -- bash -c 'apt-get install -y openssh-client; \
  [ -f /root/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N "" -f /root/.ssh/id_ed25519'

# on the host: add the container's public key
pct exec 106 -- cat /root/.ssh/id_ed25519.pub >> /root/.ssh/authorized_keys

On Proxmox, /root/.ssh/authorized_keys is a symlink to /etc/pve/priv/authorized_keys: writing to it works normally.

4.3 Second obstacle: root SSH login is disabled

The connection test kept rejecting the key. The sshd log on the host explained why:

sshd-session[...]: ROOT LOGIN REFUSED FROM 192.168.0.25

sshd_config had PermitRootLogin no. I didn’t want to open root to the whole network, so I enabled login with a key only and only from the container’s IP, using a Match block in a dedicated file:

cat > /etc/ssh/sshd_config.d/99-borgui.conf <<'EOF'
Match Address 192.168.0.25
    PermitRootLogin prohibit-password
EOF
sshd -t && echo "config ok"

I check that the rule only applies to the container (any other address stays on no):

sshd -T -C user=root,addr=192.168.0.25,host=x | grep -i permitrootlogin   # without-password
sshd -T -C user=root,addr=192.168.0.99,host=x | grep -i permitrootlogin   # no
systemctl reload ssh

Match Address relies on the container’s IP, so it should be fixed (a DHCP reservation on the router or a static IP in the container):

pct set 106 -net0 name=eth0,bridge=vmbr0,ip=192.168.0.25/24,gw=192.168.0.1
pct reboot 106

Now the test works:

pct exec 106 -- ssh -o BatchMode=yes root@192.168.0.10 'borg --version'; echo "exit: $?"
# borg 1.4.0
# exit: 0

4.4 Configuring Borg-UI

  1. Import the SSH key. Under Remote Machines → Import Existing SSH Key you give the path of the key inside the container (/root/.ssh/id_ed25519), without pasting anything. I leave the public key path empty: it works it out by adding .pub.

  2. Add the connection. With Add Manual Connection I enter host 192.168.0.10, port 22, user root and the imported key. I don’t use Deploy to Server: it copies the key using a password, but here root password login is disabled and the key is already authorized.

  3. Verify the host key. The connection card shows Host key not verified. You check it by comparing the fingerprint with the host’s:

    ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
    

    If the SHA256:... value matches, confirm it from the shield icon.

  4. Import the existing repository. Here’s the mistake to avoid: Create Repository runs borg init and is meant for new repositories. To attach the existing one, use Import Existing:

    • path: /mnt/nas-backup/borg-proxmox
    • connection: the host one
    • encryption: repokey, with the repository passphrase
    • compression: zstd level 3, the same as the script, so new archives are consistent
  5. Don’t create a backup plan. Borg-UI offers to create plans with their own schedule and retention. As long as scheduling is handled by cron, a plan writing to the same repository would lead to lock conflicts and crossed prunes. Here Borg-UI’s role is monitoring only.

4.5 What you see

After the import, the repository card shows:

  • 26 archives, both dumps (proxmox-*) and configuration (pve-config-*);
  • 517 GB total size on the NAS;
  • the date of the last backup (this morning’s, confirming that cron works);
  • repokey encryption and zstd,3 compression.

The remote connection card also shows the host’s filesystem usage. In my case the ~94 GB is pve-root, the partition that once filled up with leftover dumps: seeing it as a percentage in a UI is a good early warning.

The repository also shows Last compaction: Never and Last check: Never. That doesn’t mean the script doesn’t compact: those fields only refer to operations started from Borg-UI. compact is run by the script at the end of the prune.


5. What I learned

  • Unprivileged containers and bind mounts don’t get along with root-owned files. The problem is structural: Borg over SSH beats remapping uids.
  • A Match Address block for PermitRootLogin limits the risk: root with a key only and only from one IP.
  • Prune needs compact afterwards (Borg 1.2 and later), otherwise the NAS fills up even with the right retention.
  • Dump cleanup must not depend on the backup result, otherwise one error sets off a spiral of full disks.
  • vzdump --compress zstd and deduplication work against each other: already-compressed files change completely from one day to the next, so Borg can barely deduplicate them. If NAS space is a problem, consider running vzdump without compression, keeping in mind that it needs more temporary space on the host.
  • Change the passphrase if it ended up somewhere unsafe: with repokey it’s a single command.

In the next guide I explain how to restore VMs, containers and configuration from these archives.