Backup di Proxmox con BorgBackup e controllo via Borg-UI

Nel mio homelab ho un nodo Proxmox VE (host01) e un NAS Synology. Volevo un backup automatico, deduplicato e cifrato di VM, container e configurazione dell’host, con un’interfaccia web per consultare gli archivi senza dover ricordare ogni comando.

La soluzione è composta da due parti con ruoli ben distinti:

ComponenteDove giraRuolo
Script bash + cronHost ProxmoxEsecutore: crea i dump, li archivia con Borg, fa prune e compact
Borg-UIContainer LXC dedicatoInterfaccia di controllo: elenco archivi, statistiche, restore, controlli

Il repository Borg sta sul NAS, esportato via NFS e montato sull’host in /mnt/nas-backup.

Perché non far eseguire tutto a Borg-UI? vzdump e la cartella dei dump esistono solo sull’host. Borg-UI gira in un container e dovrebbe far transitare centinaia di GB dall’host al container e di nuovo verso il NAS. Lo script sull’host è più semplice e più efficiente. Borg-UI resta il posto dove guardare cosa è successo.


1. Lo script di backup

Lo script fa, nell’ordine:

  1. verifica che il NAS sia montato;
  2. esegue vzdump di tutte le VM e i container (modalità snapshot, compressione zstd);
  3. archivia la cartella dei dump in Borg;
  4. archivia la configurazione di Proxmox in un archivio separato;
  5. applica la retention (prune) e libera lo spazio (compact);
  6. sempre, anche in caso di errore, cancella i dump locali più vecchi di 3 ore;
  7. manda una mail con l’esito.

Salvalo in /usr/local/bin/proxmox-borg-backup.sh:

#!/bin/bash
# ==============================================================
# proxmox-borg-backup.sh
# Backup completo Proxmox VE → BorgBackup su NAS
# ==============================================================

set -euo pipefail

# ── Configurazione ──────────────────────────────────────────
BORG_REPO="/mnt/nas-backup/borg-proxmox"
# La passphrase NON sta nello script: vedi /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="tuo-indirizzo@example.com"
EMAIL_FROM="mittente@example.com"


# ── Funzioni ────────────────────────────────────────────────
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 non montato. Tentativo di mount..."
        mount /mnt/nas-backup || { log "ERRORE: Mount fallito."; return 1; }
    fi
    log "NAS montato correttamente."
}

backup_vms_and_ct() {
    log "=== Avvio dump VM e Container ==="
    vzdump --all \
           --compress zstd \
           --mode snapshot \
           --dumpdir "$DUMP_DIR" \
           --quiet \
           2>&1 | tee -a "$LOG_FILE"
    log "Dump completato."
}

backup_pve_config() {
    log "=== Backup configurazione Proxmox ==="
    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 "=== Trasferimento dump in Borg ==="
    borg create \
        --stats \
        --compression zstd,3 \
        "${BORG_REPO}::proxmox-${DATE}" \
        "$DUMP_DIR" \
        --exclude "$DUMP_DIR/*.log" \
        2>&1 | tee -a "$LOG_FILE"
    log "Archivio Borg creato: proxmox-${DATE}"
}

prune_old_backups() {
    log "=== Pulizia archivi vecchi ==="
    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"

    # Con Borg 1.2+ il prune non libera spazio: serve il compact
    borg compact "$BORG_REPO" 2>&1 | tee -a "$LOG_FILE"
}

cleanup_dumps() {
    log "=== Pulizia dump temporanei ==="
    find "$DUMP_DIR" -name "vzdump-*" -mmin +180 -delete
    log "Dump temporanei rimossi."
}

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

log "======================================================"
log "Avvio backup Proxmox → Borg | ${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 gira SEMPRE, anche se un backup precedente è fallito,
# per evitare che i dump locali si accumulino e riempiano lo spazio su /
log "=== Pulizia dump locali (indipendente da esito backup) ==="
cleanup_dumps || log "ATTENZIONE: cleanup_dumps ha riportato un errore"

if $BACKUP_OK; then
    log "Backup completato con successo!"
    printf "Subject: Backup Proxmox OK - %s\nFrom: %s\nTo: %s\n\nBackup completato con successo il %s.\n\nDettagli nel log:\n%s" \
        "$DATE" "$EMAIL_FROM" "$EMAIL_TO" "$DATE" \
        "$(tail -20 "$LOG_FILE")" \
        | msmtp "$EMAIL_TO"
else
    log "ERRORE: il backup non è andato a buon fine."
    printf "Subject: Backup Proxmox FALLITO - %s\nFrom: %s\nTo: %s\n\nIl backup del %s è fallito.\n\nUltime righe del log:\n%s" \
        "$DATE" "$EMAIL_FROM" "$EMAIL_TO" "$DATE" \
        "$(tail -40 "$LOG_FILE")" \
        | msmtp "$EMAIL_TO"
    exit 1
fi

Due scelte di progetto che contano

cleanup_dumps fuori dalla catena &&. In una prima versione la pulizia dei dump era agganciata con && al resto. Se un backup falliva, la pulizia non girava mai: i dump rimanevano sul disco, riempivano pve-root e il backup successivo falliva a sua volta. Un circolo vizioso. Oggi la pulizia è indipendente dall’esito.

Due prefissi, due retention. Gli archivi si chiamano proxmox-DATA (i dump) e pve-config-DATA (la configurazione). Il prune usa --glob-archives per applicare la retention a ciascun gruppo separatamente: così una serie di backup falliti non cancella per errore gli archivi dell’altro tipo.


2. La passphrase fuori dallo script

Mettere BORG_PASSPHRASE in chiaro nello script è comodo ma pessimo: finisce nei backup, negli screenshot e in qualunque copia dello script. La sposto in un file leggibile solo da root.

# crea il file con permessi restrittivi fin dall'inizio
( umask 077; cat > /etc/borg-backup.env <<'EOF'
export BORG_PASSPHRASE="LA-TUA-PASSPHRASE"
EOF
)
chown root:root /etc/borg-backup.env
chmod 600 /etc/borg-backup.env

Verifica che funzioni:

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

Se la passphrase è finita in chiaro in qualche posto non sicuro, cambiala. Con repokey il cambio è immediato e non riscrive il repository:

source /etc/borg-backup.env
borg key change-passphrase /mnt/nas-backup/borg-proxmox
# poi aggiorna il valore in /etc/borg-backup.env

Esporta anche la chiave del repository e conservala fuori dall’host, insieme alla passphrase, nel password manager. Senza chiave e passphrase il repository non si apre più:

borg key export /mnt/nas-backup/borg-proxmox /root/borg-key-backup.txt
# copiala altrove e poi cancella il file locale

3. Pianificazione con cron

Rendi lo script eseguibile e provalo a mano una volta, osservando il log:

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

Poi pianificalo con crontab -e come root:

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

Parte ogni giorno alle 08:00. Scegli un orario in cui l’host e il NAS non siano impegnati in altro. Il NAS, ad esempio, può essere impostato per ibernare i dischi dopo un periodo di inattività: il montaggio all’inizio dello script lo risveglia.

Per controllare cosa è pianificato:

crontab -l

Attenzione al lock. Borg tiene un lock sul repository mentre lavora. Non lanciare lo script a mano mentre sta girando il cron, e non avviare un controllo dalla UI nello stesso momento.


4. Borg-UI come interfaccia di controllo

Borg-UI si installa su Proxmox con lo script della community (community-scripts), che crea un container LXC con Docker già pronto. Io l’ho messo nel CT 106, con 2 core, 4 GB di RAM e IP fisso.

L’obiettivo: far vedere a Borg-UI il repository che sta sul NAS e che l’host scrive ogni mattina. Ecco tutti i passaggi, compresi gli ostacoli incontrati.

4.1 Il primo ostacolo: i permessi sul bind mount

Il container è unprivileged e ha un bind mount verso /mnt/nas-backup. Ho provato a leggere il repository dal container:

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

La causa: in un container unprivileged l’utente root dell’host (uid 0) viene mappato su nobody (uid 65534). I file del repository appartengono a root con permessi 0600, quindi dal container non sono leggibili.

Ci sono due strade: rimappare gli uid con lxc.idmap (delicato, perché tocca i permessi di tutto il mount) oppure far eseguire Borg sull’host via SSH. Ho scelto la seconda: Borg gira sull’host come root e il container fa solo da interfaccia.

4.2 Chiave SSH dal container verso l’host

Genero una chiave nel container e la autorizzo sull’host:

# nel container: installa il client e genera la chiave
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'

# sull'host: aggiungi la chiave pubblica del container
pct exec 106 -- cat /root/.ssh/id_ed25519.pub >> /root/.ssh/authorized_keys

Su Proxmox /root/.ssh/authorized_keys è un link simbolico a /etc/pve/priv/authorized_keys: la scrittura funziona normalmente.

4.3 Secondo ostacolo: il root login via SSH è disabilitato

Il test di connessione continuava a rifiutare la chiave. Il log di sshd sull’host ha spiegato il motivo:

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

Nel sshd_config c’era PermitRootLogin no. Non volevo riaprire root a tutta la rete, quindi ho abilitato il login solo con chiave e solo dall’IP del container, con un blocco Match in un file dedicato:

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"

Verifico che la regola valga solo per il container (qualsiasi altro indirizzo resta su 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

Il Match Address si basa sull’IP del container, quindi conviene che sia fisso (riserva DHCP sul router o IP statico nel container):

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

Ora il test funziona:

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

4.4 Configurazione in Borg-UI

  1. Importare la chiave SSH. In Macchine Remote → Importa Chiave SSH Esistente si indica il percorso della chiave dentro il container (/root/.ssh/id_ed25519), senza incollare nulla. Lascio vuoto il percorso della chiave pubblica: lo ricava da solo aggiungendo .pub.

  2. Aggiungere la connessione. Con Aggiungi Connessione Manuale inserisco host 192.168.0.10, porta 22, utente root e la chiave importata. Non uso Distribuisci su Server: serve a copiare la chiave tramite password, ma qui il login con password di root è disabilitato e la chiave è già autorizzata.

  3. Verificare l’host key. La scheda della connessione mostra Chiave host non verificata. Si controlla confrontando l’impronta con quella dell’host:

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

    Se il valore SHA256:... coincide, si conferma dall’icona dello scudo.

  4. Importare il repository esistente. Qui l’errore da evitare: Crea Repository esegue un borg init e serve per repository nuovi. Per agganciare quello esistente si usa Importa Esistente:

    • percorso: /mnt/nas-backup/borg-proxmox
    • connessione: quella dell’host
    • crittografia: repokey, con la passphrase del repository
    • compressione: zstd livello 3, la stessa dello script, così i nuovi archivi sono coerenti
  5. Non creare un piano di backup. Borg-UI propone di creare piani con schedulazione e retention proprie. Finché la pianificazione è affidata al cron, creare un piano che scrive nello stesso repository porterebbe a conflitti di lock e a prune incrociati. Il ruolo di Borg-UI qui è solo il controllo.

4.5 Cosa si vede

Dopo l’importazione la scheda del repository mostra:

  • 26 archivi, tra dump (proxmox-*) e configurazione (pve-config-*);
  • 517 GB di dimensione totale sul NAS;
  • data dell’ultimo backup (quello del mattino, a conferma che il cron funziona);
  • crittografia repokey e compressione zstd,3.

La scheda della connessione remota mostra anche lo spazio del filesystem dell’host. Nel mio caso i ~94 GB corrispondono a pve-root, la partizione che in passato si era riempita di dump rimasti: vederla in percentuale da una UI è un buon sistema di allerta.

Sul repository compaiono anche Ultima compattazione: Mai e Ultimo controllo: Mai. Non significa che lo script non compatti: quei campi si riferiscono solo alle operazioni lanciate da Borg-UI. Il compact è eseguito dallo script alla fine del prune.


5. Cosa ho imparato

  • Container unprivileged e bind mount non vanno d’accordo con i file di root. Il problema è strutturale: meglio Borg via SSH che rimappare gli uid.
  • Un blocco Match Address per PermitRootLogin limita il rischio: root con sola chiave e solo da un IP.
  • Dopo il prune serve il compact (Borg 1.2 e successivi), altrimenti il NAS si riempie anche con la retention corretta.
  • La pulizia dei dump deve essere indipendente dall’esito del backup, altrimenti un errore innesca una spirale di dischi pieni.
  • vzdump --compress zstd e deduplicazione si ostacolano: i file già compressi cambiano completamente da un giorno all’altro, quindi Borg riesce a deduplicarli poco. Se lo spazio sul NAS è un problema, si può valutare di eseguire vzdump senza compressione, ricordando che richiede più spazio temporaneo sull’host.
  • Cambiare la passphrase se è finita in un posto non sicuro: con repokey è questione di un comando.

Nella guida successiva spiego come ripristinare VM, container e configurazione da questi archivi.