Gestire un server personale significa accettare una responsabilità molto semplice: prima o poi qualcosa si romperà.
Disco che muore, aggiornamento andato storto, errore umano, oppure la necessità di migrare su una macchina nuova.

Questo post raccoglie la procedura che utilizzo sul mio server YunoHost per avere:

  • backup automatici e verificati;
  • una copia esterna e fisica dei dati;
  • una procedura di ripristino.

È scritto prima di tutto come promemoria personale.


Strategia di backup

  1. Backup ufficiale YunoHost

    • uso yunohost backup create (system + apps);
    • mantengo solo gli ultimi backup automatici.
  2. Snapshot giornalieri su disco esterno

    • disco dedicato, montato solo durante il backup;
    • snapshot incrementali con rsync --link-dest;
    • rotazione automatica (7 giorni).
  3. Verifica dell’integrità

    • controllo dei file .tar sia internamente sia sul disco esterno.
  4. Recovery esplicito

    • script separato;
    • scelta manuale dello snapshot;
    • conferma prima del restore.

Il disco esterno è offline, si monta automaticamente per il backup e poi viene, sempre automaticamente smontato.


Struttura dei backup

Sul disco esterno:

/mnt/disco-backup/
└── yunohost/
    └── giornaliero/
        ├── 2025-12-07-2302/
        │   ├── archives/
        │   ├── etc/
        │   └── home/
        ├── 2025-12-08-0300/
        └── ...

Ogni directory rappresenta uno snapshot coerente del sistema.


Preparazione del disco esterno

Identificazione del disco

lsblk -o NAME,SIZE,TYPE,MOUNTPOINT

Supponiamo che il disco esterno sia /dev/sda.


Inizializzazione

sudo wipefs -a /dev/sda
sudo fdisk /dev/sda

In fdisk:

  • g → nuova tabella GPT
  • n → nuova partizione
  • w → salva

Risultato atteso: /dev/sda1.


Creazione filesystem

sudo mkfs.ext4 -L disco-backup /dev/sda1
lsblk -f

Test del mount

sudo mkdir -p /mnt/disco-backup
sudo mount /dev/disk/by-label/disco-backup /mnt/disco-backup
df -h | grep disco-backup
sudo umount /mnt/disco-backup

Struttura directory sul disco

sudo mkdir -p /mnt/disco-backup/yunohost/giornaliero
sudo ls -R /mnt/disco-backup
sudo umount /mnt/disco-backup

Script di backup giornaliero

Questo script:

  • crea un backup completo YunoHost (--system --apps);
  • verifica l’integrità dell’archivio interno (tar -tf);
  • monta il disco esterno;
  • crea uno snapshot datato e sincronizza:
    • /home/yunohost.backup/archives
    • /etc
    • /home (escludendo la copia ridondante degli archivi);
  • verifica l’integrità dello snapshot sul disco esterno;
  • mantiene solo gli ultimi 7 snapshot giornalieri sul disco esterno;
  • mantiene solo gli ultimi 3 backup interni auto-*;
  • smonta il disco.

Creazione file

sudo nano /usr/local/sbin/backup_yunohost_giornaliero.sh

Contenuto script (copia/incolla)

#!/usr/bin/env bash
set -euo pipefail

# ==========================
# CONFIGURAZIONE
# ==========================

# Etichetta del disco esterno
DISK_LABEL="disco-backup"

# Mount point
MOUNT_POINT="/mnt/disco-backup"

# Directory base per i backup giornalieri sul disco esterno
BASE_DIR="$MOUNT_POINT/yunohost/giornaliero"

# Quanti snapshot giornalieri tenere sul disco esterno
KEEP_DAILY=7

# Dove YunoHost salva gli archivi di backup
YH_BACKUP_DIR="/home/yunohost.backup/archives"

# Quanti backup interni auto-* tenere
KEEP_INTERNAL_AUTO=3

# Cosa sincronizzare sul disco esterno
SOURCES=(
  "$YH_BACKUP_DIR"  # archivi ufficiali YunoHost
  "/etc"            # configurazione sistema
  "/home"           # dati utenti, app, ecc.
)

# ==========================
# FUNZIONI DI UTILITÀ
# ==========================

log() {
  echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"
}

error() {
  log "ERRORE: $*"
  exit 1
}

mount_disk() {
  log "Verifica presenza disco con etichetta '$DISK_LABEL'..."
  if [ ! -e "/dev/disk/by-label/${DISK_LABEL}" ]; then
    error "Disco con etichetta '${DISK_LABEL}' non trovato. È collegato?"
  fi

  mkdir -p "$MOUNT_POINT"

  if mountpoint -q "$MOUNT_POINT"; then
    log "Il disco risulta già montato su $MOUNT_POINT."
  else
    log "Monto il disco su $MOUNT_POINT..."
    mount "/dev/disk/by-label/${DISK_LABEL}" "$MOUNT_POINT"
  fi

  if ! mountpoint -q "$MOUNT_POINT"; then
    error "Impossibile montare il disco su $MOUNT_POINT."
  fi

  log "Disco montato correttamente."
}

umount_disk() {
  if mountpoint -q "$MOUNT_POINT"; then
    log "Smonto il disco da $MOUNT_POINT..."
    umount "$MOUNT_POINT" || log "ATTENZIONE: impossibile smontare $MOUNT_POINT (forse in uso)."
  fi
}

cleanup() {
  umount_disk
}
trap cleanup EXIT

check_internal_backup_integrity() {
  local backup_name="$1"
  local tar_path="$YH_BACKUP_DIR/${backup_name}.tar"

  log "Verifico integrità archivio interno YunoHost: $tar_path"

  if [ ! -f "$tar_path" ]; then
    error "Archivio YunoHost non trovato: $tar_path"
  fi

  # Controllo base: tar deve riuscire a leggerlo
  if ! tar -tf "$tar_path" > /dev/null 2>&1; then
    error "Archivio YunoHost corrotto o illeggibile: $tar_path"
  fi

  log "Archivio interno YunoHost OK."
}

check_snapshot_integrity() {
  local snap_dir="$1"
  local backup_name="$2"

  log "Verifica integrità snapshot su disco esterno: $snap_dir"

  # Controllo struttura minima
  [[ -d "$snap_dir/etc" ]] || error "Manca la directory etc nello snapshot: $snap_dir"
  [[ -d "$snap_dir/home" ]] || error "Manca la directory home nello snapshot: $snap_dir"
  [[ -d "$snap_dir/archives" ]] || error "Manca la directory archives nello snapshot: $snap_dir"

  local snap_tar="$snap_dir/archives/${backup_name}.tar"

  if [ ! -f "$snap_tar" ]; then
    error "Archivio YunoHost non trovato nello snapshot: $snap_tar"
  fi

  log "Verifico integrità archivio YunoHost nello snapshot: $snap_tar"
  if ! tar -tf "$snap_tar" > /dev/null 2>&1; then
    error "Archivio YunoHost corrotto nello snapshot: $snap_tar"
  fi

  log "Snapshot su disco esterno OK."
}

cleanup_internal_backups() {
  log "Pulizia backup interni auto-* (mantengo ultimi $KEEP_INTERNAL_AUTO)..."

  cd "$YH_BACKUP_DIR" || {
    log "ATTENZIONE: impossibile entrare in $YH_BACKUP_DIR, salto pulizia interna."
    return
  }

  # Lista dei backup auto-*.tar (senza estensione), ordinati
  mapfile -t INTERNAL < <(ls -1 auto-*.tar 2>/dev/null | sed 's/\.tar$//' | sort || true)

  local num=${#INTERNAL[@]}

  if (( num <= KEEP_INTERNAL_AUTO )); then
    log "Nessuna eliminazione necessaria: backup interni auto-* = $num (<= $KEEP_INTERNAL_AUTO)."
    return
  fi

  local to_delete=$((num - KEEP_INTERNAL_AUTO))
  log "Trovati $num backup interni auto-*, ne elimino $to_delete più vecchi."

  for (( i=0; i<to_delete; i++ )); do
    local name="${INTERNAL[$i]}"
    log "Elimino backup interno vecchio: $name.(tar|info.json)"
    rm -f "${name}.tar" "${name}.info.json"
  done
}

# ==========================
# INIZIO SCRIPT
# ==========================

log "===== INIZIO BACKUP GIORNALIERO YUNOHOST ====="

# 1) CREA UN BACKUP COMPLETO YUNOHOST
BACKUP_NAME="auto-$(date +'%Y-%m-%d-%H%M')"
log "Creo backup YunoHost: $BACKUP_NAME"
yunohost backup create --system --apps --name "$BACKUP_NAME"

log "Backup YunoHost completato. Archivi in: $YH_BACKUP_DIR"

# 1b) CONTROLLO DI INTEGRITÀ ARCHIVIO INTERNO
check_internal_backup_integrity "$BACKUP_NAME"

# 2) MONTA DISCO ESTERNO
mount_disk

# Assicuriamoci che la directory base esista
mkdir -p "$BASE_DIR"

# Nome dello snapshot corrente: es. 2025-12-07-2302
SNAP_NAME="$(date +'%Y-%m-%d-%H%M')"
SNAP_DIR="$BASE_DIR/$SNAP_NAME"

log "Creo snapshot corrente sul disco esterno: $SNAP_DIR"
mkdir -p "$SNAP_DIR"

# Trova l'ultimo snapshot (se esiste)
LAST_SNAP="$(ls -1d "$BASE_DIR"/20* 2>/dev/null | sort | tail -n 1 || true)"

if [ -n "$LAST_SNAP" ]; then
  log "Ultimo snapshot precedente trovato: $LAST_SNAP"
else
  log "Nessun snapshot precedente trovato (primo backup sul disco esterno)."
fi

# Opzioni rsync
RSYNC_OPTS="-aHAX --numeric-ids --delete --info=progress2"

# 3) SINCRONIZZA LE SORGENTI SUL DISCO ESTERNO
for SRC in "${SOURCES[@]}"; do
  if [ ! -d "$SRC" ]; then
    log "ATTENZIONE: sorgente $SRC non trovata, salto..."
    continue
  fi

  SRC_NAME="$(basename "$SRC")"
  DEST_DIR="$SNAP_DIR/$SRC_NAME"

  mkdir -p "$DEST_DIR"

  log "Backup di $SRC -> $DEST_DIR"

  # Opzioni extra per alcune sorgenti
  EXTRA_OPTS=()
  if [ "$SRC" = "/home" ]; then
    # Evita di duplicare gli archivi (li abbiamo già in $YH_BACKUP_DIR)
    EXTRA_OPTS+=(--exclude='yunohost.backup/archives')
  fi

  # Se esiste snapshot precedente con stessa struttura, usa --link-dest
  if [ -n "$LAST_SNAP" ] && [ -d "$LAST_SNAP/$SRC_NAME" ]; then
    LINK_DEST="--link-dest=$LAST_SNAP/$SRC_NAME"
    log "Uso rsync incrementale con hard-link (--link-dest=$LAST_SNAP/$SRC_NAME)."
  else
    LINK_DEST=""
    log "Nessun --link-dest disponibile per $SRC (farà una copia completa)."
  fi

  rsync $RSYNC_OPTS "${EXTRA_OPTS[@]}" $LINK_DEST "$SRC"/ "$DEST_DIR"/
done

# 3b) CONTROLLO DI INTEGRITÀ DELLO SNAPSHOT SU DISCO ESTERNO
check_snapshot_integrity "$SNAP_DIR" "$BACKUP_NAME"

# 4) ROTAZIONE SNAPSHOT SUL DISCO ESTERNO
log "Gestione rotazione snapshot (mantengo ultimi $KEEP_DAILY)..."

mapfile -t BACKUPS < <(ls -1d "$BASE_DIR"/20* 2>/dev/null | sort || true)
NUM=${#BACKUPS[@]}

if (( NUM > KEEP_DAILY )); then
  TO_DELETE=$((NUM - KEEP_DAILY))
  log "Trovati $NUM snapshot, ne elimino $TO_DELETE più vecchi."
  for (( i=0; i<TO_DELETE; i++ )); do
    OLD="${BACKUPS[$i]}"
    log "Elimino snapshot vecchio: $OLD"
    rm -rf --one-file-system "$OLD"
  done
else
  log "Nessuna eliminazione necessaria: snapshot presenti = $NUM (<= $KEEP_DAILY)."
fi

# 5) PULIZIA BACKUP INTERNI AUTO-*
cleanup_internal_backups

log "===== FINE BACKUP GIORNALIERO YUNOHOST ====="

Rendere eseguibile lo script

sudo chmod +x /usr/local/sbin/backup_yunohost_giornaliero.sh

Test manuale

sudo /usr/local/sbin/backup_yunohost_giornaliero.sh

Recovery da disco esterno

Questa sezione descrive come ripristinare un server YunoHost da un backup salvato sul disco esterno disco-backup.

Lo script:

  • monta il disco esterno;
  • sceglie lo snapshot e l’archivio di backup;
  • copia l’archivio in /home/yunohost.backup/archives;
  • verifica l’integrità del .tar;
  • chiede conferma;
  • esegue yunohost backup restore.

Creazione script

sudo nano /usr/local/sbin/yunohost_recovery_from_disk.sh

Contenuto script

#!/usr/bin/env bash
set -euo pipefail

# ==========================
# CONFIGURAZIONE
# ==========================

# Etichetta del disco esterno con i backup
DISK_LABEL="disco-backup"

# Punto di mount del disco esterno
MOUNT_POINT="/mnt/disco-backup"

# Directory base degli snapshot giornalieri sul disco esterno
BASE_DIR="$MOUNT_POINT/yunohost/giornaliero"

# Directory interna dove YunoHost si aspetta gli archivi di backup
YH_BACKUP_DIR="/home/yunohost.backup/archives"

# ==========================
# FUNZIONI DI UTILITÀ
# ==========================

log() {
  echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"
}

error() {
  log "ERRORE: $*"
  exit 1
}

mount_disk() {
  log "Verifico presenza disco con etichetta '$DISK_LABEL'..."
  if [ ! -e "/dev/disk/by-label/${DISK_LABEL}" ]; then
    error "Disco con etichetta '${DISK_LABEL}' non trovato. È collegato?"
  fi

  mkdir -p "$MOUNT_POINT"

  if mountpoint -q "$MOUNT_POINT"; then
    log "Il disco risulta già montato su $MOUNT_POINT."
  else
    log "Monto il disco su $MOUNT_POINT..."
    mount "/dev/disk/by-label/${DISK_LABEL}" "$MOUNT_POINT"
  fi

  if ! mountpoint -q "$MOUNT_POINT"; then
    error "Impossibile montare il disco su $MOUNT_POINT."
  fi

  log "Disco montato correttamente."
}

umount_disk() {
  if mountpoint -q "$MOUNT_POINT"; then
    log "Smonto il disco da $MOUNT_POINT..."
    umount "$MOUNT_POINT" || log "ATTENZIONE: impossibile smontare $MOUNT_POINT (forse in uso). Lascio montato."
  fi
}

cleanup() {
  umount_disk
}
trap cleanup EXIT

choose_snapshot() {
  local snapshot_arg="$1"

  if [ -n "$snapshot_arg" ]; then
    # Può essere un nome tipo 2025-12-07-2302 o un path completo
    if [[ "$snapshot_arg" == /* ]]; then
      SNAP_DIR="$snapshot_arg"
    else
      SNAP_DIR="$BASE_DIR/$snapshot_arg"
    fi
  else
    log "Nessuno snapshot specificato, scelgo l'ultimo disponibile..."
    SNAP_DIR="$(ls -1d "$BASE_DIR"/20* 2>/dev/null | sort | tail -n 1 || true)"
  fi

  if [ -z "${SNAP_DIR:-}" ] || [ ! -d "$SNAP_DIR" ]; then
    error "Snapshot non trovato. Controlla che ci siano directory tipo $BASE_DIR/2025-12-07-2302"
  fi

  log "Snapshot selezionato: $SNAP_DIR"
}

choose_backup_name() {
  local backup_arg="$1"
  local archives_dir="$SNAP_DIR/archives"

  if [ ! -d "$archives_dir" ]; then
    error "La directory archives non esiste nello snapshot: $archives_dir"
  fi

  if [ -n "$backup_arg" ]; then
    BACKUP_NAME="$backup_arg"
  else
    log "Nessun nome di backup specificato, scelgo l'ultimo backup auto-* nello snapshot..."
    local latest_tar
    latest_tar="$(ls -1 "$archives_dir"/auto-*.tar 2>/dev/null | sort | tail -n 1 || true)"
    if [ -z "$latest_tar" ]; then
      error "Nessun archivio auto-*.tar trovato in $archives_dir"
    fi
    BACKUP_NAME="$(basename "$latest_tar" .tar)"
  fi

  log "Backup selezionato: $BACKUP_NAME (snapshot: $SNAP_DIR)"
}

copy_backup_to_local() {
  local src_tar="$SNAP_DIR/archives/${BACKUP_NAME}.tar"
  local src_info="$SNAP_DIR/archives/${BACKUP_NAME}.info.json"

  if [ ! -f "$src_tar" ]; then
    error "Archivio .tar non trovato nello snapshot: $src_tar"
  fi

  mkdir -p "$YH_BACKUP_DIR"

  log "Copio l'archivio di backup su $YH_BACKUP_DIR..."
  cp -a "$src_tar" "$YH_BACKUP_DIR/"

  if [ -f "$src_info" ]; then
    log "Copio anche il file info.json..."
    cp -a "$src_info" "$YH_BACKUP_DIR/"
  else
    log "ATTENZIONE: file .info.json non trovato per $BACKUP_NAME, procedo solo con .tar."
  fi

  LOCAL_TAR="$YH_BACKUP_DIR/${BACKUP_NAME}.tar"
}

check_local_backup_integrity() {
  log "Verifico integrità dell'archivio locale: $LOCAL_TAR"
  if ! tar -tf "$LOCAL_TAR" > /dev/null 2>&1; then
    error "Archivio locale corrotto o illeggibile: $LOCAL_TAR"
  fi
  log "Archivio locale OK."
}

run_restore() {
  log "Pronto a eseguire: yunohost backup restore $BACKUP_NAME"
  echo
  echo "ATTENZIONE:"
  echo "  - Questo ripristino sovrascriverà la configurazione e i dati del server YunoHost corrente."
  echo "  - Assicurati di eseguirlo su una reinstallazione pulita o di sapere cosa stai facendo."
  echo
  read -r -p "Vuoi procedere con il ripristino? [yes/N]: " answer
  case "$answer" in
    yes|y|Y)
      log "Eseguo il ripristino..."
      yunohost backup restore "$BACKUP_NAME"
      log "Ripristino completato (controlla l'output di yunohost per eventuali messaggi)."
      ;;
    *)
      log "Ripristino annullato dall'utente."
      exit 1
      ;;
  esac
}

# ==========================
# INIZIO SCRIPT
# ==========================

# Argomenti:
#  $1 = (opzionale) nome snapshot (es. 2025-12-07-2302) o path completo
#  $2 = (opzionale) nome backup (es. auto-2025-12-07-2301)
SNAPSHOT_ARG="${1:-}"
BACKUP_ARG="${2:-}"

log "===== SCRIPT DI RECOVERY YUNOHOST DA DISCO ESTERNO ====="

mount_disk
choose_snapshot "$SNAPSHOT_ARG"
choose_backup_name "$BACKUP_ARG"
copy_backup_to_local
check_local_backup_integrity
run_restore

log "===== FINE SCRIPT DI RECOVERY ====="

Rendere eseguibile lo script

sudo chmod +x /usr/local/sbin/yunohost_recovery_from_disk.sh

Utilizzo

Ultimo snapshot e ultimo backup auto-*:

sudo /usr/local/sbin/yunohost_recovery_from_disk.sh

Snapshot specifico:

sudo /usr/local/sbin/yunohost_recovery_from_disk.sh 2025-12-07-2302

Snapshot e nome backup preciso:

sudo /usr/local/sbin/yunohost_recovery_from_disk.sh 2025-12-07-2302 auto-2025-12-07-2301