tva
← Approfondimenti

Disaster recovery per servizi sulla propria infrastruttura: la nostra strategia di backup

Il self-hosting offre un controllo che i servizi gestiti non consentono: puoi scegliere l’hardware, la rete, la residenza dei dati e la struttura dei costi. Questo controllo comporta però una responsabilità di cui i servizi gestiti si occupano senza che tu debba intervenire: quando qualcosa va storto, il problema è interamente tuo. In una configurazione con un solo server che esegue più servizi di produzione – Supabase, un CRM, un DAM, un server Git – la strategia di backup e ripristino non è una questione secondaria. È ciò che distingue un incidente risolvibile da una perdita definitiva di dati.

Questo articolo documenta la strategia di backup che utilizziamo in un ambiente di produzione gestito sulla nostra infrastruttura. I servizi coinvolti sono uno stack Supabase (con PostgreSQL come archivio dati principale), un CRM sulla nostra infrastruttura (Twenty, anch’esso basato su PostgreSQL), un server Git Gitea e un sistema di gestione delle risorse digitali. Tutti vengono eseguiti come container Docker su un unico VPS Hetzner. La strategia deve coprirli tutti senza creare periodi di carico inaccettabile o complesse dipendenze di orchestrazione.

Perché un unico server aumenta il rischio

Una configurazione con un solo server non dispone di ridondanza integrata. Non ci sono repliche in standby, failover tra più zone di disponibilità o ripristini automatici del database attivati da un controllo dello stato. Quando il server si guasta – per un problema hardware, un aggiornamento mal riuscito, un problema di archiviazione o un incidente di sicurezza – l’unica via di ripristino è il backup. Se il backup è incompleto, non aggiornato o mai testato, le possibilità di ripristino sono limitate di conseguenza.

Il modello di minaccia è diverso da quello di un servizio cloud gestito, dove i guasti hardware sono in gran parte invisibili perché il fornitore dell’infrastruttura gestisce il failover. Su un singolo VPS devi pianificare esplicitamente la risposta ai tipi di guasto di cui i servizi gestiti si occupano al posto tuo. La pianificazione non è particolarmente complessa, ma va fatta prima dell’incidente, non durante.

Di cosa eseguire il backup

Ogni servizio ha caratteristiche diverse per quanto riguarda i dati. Capire quali dati sono insostituibili e dove risiedono è il presupposto per progettare la strategia di backup.

Supabase conserva i dati in un’istanza PostgreSQL gestita dallo stack Supabase. Il database contiene tutti i dati applicativi, i record degli utenti e i metadati di autenticazione e archiviazione di Supabase stesso. I bucket di archiviazione (i file caricati) risiedono su disco e devono essere gestiti separatamente dal backup del database.

Twenty CRM utilizza una propria istanza PostgreSQL, separata da quella di Supabase. Contiene record dei contatti, dati sulle opportunità commerciali e stato dei flussi di lavoro. Questo database è più piccolo, ma contiene dati molto sensibili: perdere i dati del CRM compromette le operazioni più della perdita di dati applicativi che possono essere ricreati.

Gitea conserva i repository su disco come oggetti Git in repository bare. In linea di principio, i dati dei repository possono essere ricostruiti dalle postazioni degli sviluppatori (dato che un clone completo, non superficiale, contiene la cronologia che ha recuperato, ma non gli oggetti Git LFS né i riferimenti che non ha mai recuperato), ma i dati del sistema di gestione delle issue, i commenti alle pull request e la configurazione del team risiedono solo nel database di Gitea e non sono presenti in alcun clone. Occorre eseguire il backup sia degli oggetti Git sia del database di Gitea.

Il DAM (Digital Asset Management) conserva file originali, versioni derivate elaborate e metadati. I file originali sono la parte insostituibile; le versioni derivate possono essere rigenerate. I metadati risiedono in un database che registra le relazioni tra i file, i tag e i diritti di utilizzo.

Orchestrazione di pg_dump

pg_dump di PostgreSQL è lo strumento adatto ai backup logici dei database. Esporta una rappresentazione SQL del database che può essere ripristinata su qualsiasi versione compatibile di PostgreSQL, offrendo maggiore portabilità rispetto ai backup fisici (che richiedono la stessa versione di PostgreSQL e la stessa struttura binaria).

Lo script di backup per ogni istanza PostgreSQL segue lo stesso schema:

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

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_NAME="$1"
CONTAINER_NAME="$2"
BACKUP_DIR="/opt/backups/postgres"
OUTPUT_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"

mkdir -p "${BACKUP_DIR}"

docker exec "${CONTAINER_NAME}"   pg_dump -U postgres -d "${DB_NAME}"   | gzip -9 > "${OUTPUT_FILE}"

echo "Backup complete: ${OUTPUT_FILE} ($(du -sh "${OUTPUT_FILE}" | cut -f1))"

Il comando set -euo pipefail all’inizio è importante: fa terminare immediatamente lo script se un comando fallisce, inclusi i comandi in una pipeline. Senza pipefail, un pg_dump fallito seguito da un gzip riuscito lascerebbe un file compresso vuoto o troncato (il messaggio di errore viene inviato a stderr, non al file), mentre lo script continuerebbe a segnalare il successo dell’operazione; il problema emergerebbe solo al momento del ripristino.

L’output viene compresso direttamente con gzip -9, anziché in una fase successiva. Questo mantiene ridotta l’occupazione del disco ed evita di scrivere prima un dump non compresso, un aspetto importante sui server in cui lo spazio libero è una risorsa da gestire con attenzione.

Pianificazione scaglionata

Eseguire tutti i backup contemporaneamente creerebbe un periodo di contesa per l’I/O che potrebbe peggiorare le prestazioni dei servizi di cui si sta eseguendo il backup. Un’istanza Supabase sotto carico non trae vantaggio dal dover competere con pg_dump per l’I/O del disco. La soluzione è una pianificazione scaglionata: ogni backup inizia in un momento diverso, con intervalli sufficienti a consentire il completamento del precedente prima dell’avvio del successivo.

La pianificazione che utilizziamo, espressa come voci cron:

# Supabase PostgreSQL backup — 02:00 daily
0 2 * * * /opt/scripts/pg-backup.sh supabase supabase-db

# Twenty CRM PostgreSQL backup — 02:30 daily
30 2 * * * /opt/scripts/pg-backup.sh twenty twenty-db

# Gitea database backup — 03:00 daily
0 3 * * * /opt/scripts/pg-backup.sh gitea gitea-db

# DAM database backup — 03:30 daily
30 3 * * * /opt/scripts/pg-backup.sh dam dam-db

# Gitea repository objects — 04:00 daily
0 4 * * * /opt/scripts/git-objects-backup.sh

# Storage files (Supabase + DAM originals) — 04:30 daily
30 4 * * * /opt/scripts/files-backup.sh

Gli intervalli di 30 minuti tra i backup sono prudenziali: la maggior parte dei backup di database di queste dimensioni si completa in pochi minuti. Gli intervalli tengono però conto anche della fase di caricamento che segue ciascun backup: il file compresso viene caricato nello spazio di archiviazione a oggetti prima che inizi il processo successivo, evitando che sul disco locale si accumulino contemporaneamente backup di più giorni.

Caricamento nello spazio di archiviazione a oggetti e conservazione

I backup che esistono solo sullo stesso server dei servizi che proteggono non sono backup: sono istantanee che andranno perse nello stesso incidente che distrugge i dati protetti. Ogni backup deve essere caricato in una posizione di archiviazione separata prima di poter considerare completa la copia locale.

Utilizziamo un fornitore di archiviazione a oggetti compatibile con S3 (diverso dal fornitore del VPS) e carichiamo i file con rclone, che gestisce automaticamente nuovi tentativi e verifiche dei checksum dopo il trasferimento:

rclone copy "${OUTPUT_FILE}" "backup-remote:tva-backups/postgres/${DB_NAME}/"   --checksum   --transfers 1   --log-level INFO

L’opzione --checksum fa sì che rclone confronti i file in base a hash e dimensione (MD5 sulle destinazioni remote compatibili con S3), anziché data di modifica e dimensione. Insieme al controllo dell’hash che rclone esegue per impostazione predefinita dopo il trasferimento, questo permette di rilevare danni durante il trasferimento quando la destinazione remota conserva un hash utilizzabile.

La conservazione viene gestita da un processo di pulizia separato, eseguito ogni settimana. La politica prevede di conservare i backup giornalieri per sette giorni, quelli settimanali (il backup giornaliero della domenica, rinominato da un processo settimanale) per quattro settimane e quelli mensili (della prima domenica di ogni mese) per sei mesi. Questo offre un intervallo ragionevole per rilevare perdite di dati non immediatamente evidenti: una riga corrotta introdotta tre settimane prima può ancora essere recuperata da un backup settimanale.

La pulizia dei backup usa rclone delete con un filtro sulla data di modifica (--min-age), anziché basarsi solo sui nomi dei file. È un metodo più affidabile quando le convenzioni di denominazione non sono perfettamente coerenti:

rclone delete "backup-remote:tva-backups/postgres/"   --min-age 7d   --include "*_daily_*"   --dry-run

L’opzione --dry-run viene usata durante i test del processo di pulizia. Rimuovila solo dopo aver confermato che i filtri selezionano esattamente ciò che deve essere eliminato. Lo schema di --include presuppone che i backup giornalieri contengano il marcatore _daily_ nel nome; lo script di backup riportato sopra assegna ai file nomi nel formato DB_TIMESTAMP.sql.gz, quindi occorre prima allineare lo schema o i nomi dei file.

Test di ripristino

Un backup mai testato è un’ipotesi. L’unico modo per sapere se può essere ripristinato è ripristinarlo. Eseguiamo un test di ripristino mensile per ogni database, usando un container Docker temporaneo isolato dallo stack di produzione:

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

BACKUP_FILE="$1"
ROLES_FILE="${2:-}"
TEST_CONTAINER="restore-test-$(date +%s)"

# Start a temporary PostgreSQL container
docker run -d   --name "${TEST_CONTAINER}"   -e POSTGRES_PASSWORD=testpass   -e POSTGRES_DB=testdb   postgres:15-alpine

# Remove the container on exit, also when a step fails
trap 'docker rm -f "${TEST_CONTAINER}" > /dev/null' EXIT

# Wait for PostgreSQL to be ready
for _ in $(seq 1 30); do
  docker exec "${TEST_CONTAINER}" pg_isready -h 127.0.0.1 -U postgres -q && break
  sleep 1
done

# pg_dump omits roles: restore them first from a pg_dumpall --roles-only file (optional 2nd argument)
if [ -n "${ROLES_FILE}" ]; then
  docker exec -i "${TEST_CONTAINER}"   psql -U postgres -d testdb -q < "${ROLES_FILE}"
fi

# Restore the backup
zcat "${BACKUP_FILE}" | docker exec -i "${TEST_CONTAINER}"   psql -U postgres -d testdb -v ON_ERROR_STOP=1

# Verify row counts match expectations
docker exec "${TEST_CONTAINER}"   psql -U postgres -d testdb   -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"

La verifica del numero di righe non è esaustiva: controlla che le tabelle principali abbiano un numero stimato plausibile di righe (n_live_tup è una stima statistica, non un conteggio esatto), non che ogni riga sia corretta. Un test più approfondito eseguirebbe i controlli dello stato dell’applicazione sul database ripristinato. Il controllo del numero di righe rileva però i problemi più comuni: un backup troncato, un ripristino fallito che ha prodotto un database vuoto o un’incompatibilità di versione che ha causato una perdita di dati senza segnalazioni. Il ripristino viene eseguito con ON_ERROR_STOP=1, quindi la prima istruzione fallita fa fallire il test, e il trap rimuove il container in ogni caso. pg_dump non include i ruoli: se il dump fa riferimento a ruoli diversi da postgres, passate come secondo argomento l’output di pg_dumpall --roles-only del server di origine (in questo passaggio è previsto un errore per il ruolo postgres, che esiste già). L’immagine di test deve corrispondere alla versione principale di PostgreSQL dell’origine. Il dump di Supabase fa inoltre riferimento a schemi ed estensioni specifici di Supabase che l’immagine postgres standard non ha, quindi va ripristinato per il test in un’istanza Supabase seguendo la procedura di ripristino di Supabase.

Come avviene il ripristino nella pratica

La strategia di backup è utile solo quanto la procedura di ripristino che la utilizza. Il manuale operativo di ripristino – scritto come documento Markdown nel repository di configurazione del server – descrive la sequenza esatta dei passaggi per ripristinare ogni servizio su un nuovo VPS.

In caso di guasto dell’intero server, la sequenza è: predisporre un nuovo VPS, installare Docker e lo stack applicativo dal repository di configurazione, scaricare i file di backup più recenti dallo spazio di archiviazione a oggetti, ripristinare ogni database nell’ordine delle dipendenze (prima Supabase, poi il CRM e infine gli altri), ripristinare l’archiviazione dei file, aggiornare il DNS e verificare ogni servizio. Il tempo stimato per un ripristino completo è inferiore a due ore, se tutti i backup sono aggiornati e il manuale viene seguito senza improvvisazioni.

L’espressione chiave di questa stima è «seguito senza improvvisazioni». Il manuale dovrebbe essere abbastanza preciso da permettere a una persona che non ha mai lavorato sul sistema di eseguirlo con successo. I comandi devono poter essere copiati e incollati, non descritti a parole. Ogni passaggio deve prevedere una verifica. L’ambiguità in un manuale di ripristino è un rischio che si amplifica sotto lo stress di un incidente reale.

Testiamo il manuale ogni anno eseguendo un ripristino completo in un ambiente di preproduzione. Il test fa regolarmente emergere almeno un passaggio cambiato dall’ultima stesura: una versione di un’immagine Docker spostata, una chiave di configurazione rinominata o una variabile d’ambiente aggiunta. Rilevare questi cambiamenti durante un test pianificato è decisamente preferibile a scoprirli durante un ripristino reale.

Approfondimenti correlati

Per approfondire