Backup Laravel su VPS che falliscono da tre mesi senza che nessuno se ne accorga: diagnosi e strategia di ripristino
A settembre 2025 un cliente mi ha chiesto di ripristinare un database da backup perché un operatore del backoffice aveva cancellato per errore 1.400 record dalla tabella ordini del gestionale - un'operazione di "pulizia" fatta con una query DELETE senza WHERE sufficientemente restrittivo. Il gestionale era un'applicazione Laravel 10 su un VPS Contabo VPS L (10 vCPU, 30 GB RAM, 800 GB SSD), una PMI marchigiana del settore calzaturiero con circa 45 dipendenti e un fatturato di 3 milioni di euro. Il backup era configurato con spatie/laravel-backup, il pacchetto standard che la maggior parte dei progetti Laravel usa - e che funziona egregiamente quando è configurato e monitorato correttamente. Il problema: l'ultimo backup riuscito risaliva a tre mesi prima. Per 92 giorni, il cron job che doveva eseguire php artisan backup:run ogni notte alle 2 aveva fallito silenziosamente, e nessuno lo sapeva.
I 1.400 record cancellati per errore coprivano ordini degli ultimi due mesi. Il backup più recente disponibile era di tre mesi fa - un mese prima del primo ordine cancellato. Quei dati erano irrecuperabili dal backup. Li abbiamo recuperati parzialmente dal binlog di MySQL (che fortunatamente era abilitato), ma l'operazione è costata due giorni di lavoro e il risultato non era garantito. Il costo totale dell'incidente - tra il mio intervento, il tempo del personale interno per ricostruire i dati mancanti, e gli ordini in ritardo per la confusione generata - è stato stimato dal titolare in circa 12.000 euro. Il costo di un sistema di backup che funziona e che avvisa quando non funziona: circa 15 euro al mese di storage S3 e mezza giornata di configurazione iniziale.
Perché i backup su VPS unmanaged falliscono in silenzio e cosa fare per scoprirlo prima che sia troppo tardi?
Nella mia esperienza su decine di VPS che ho preso in carico per PMI, i backup falliti sono la norma, non l'eccezione. Il pattern è sempre lo stesso: qualcuno configura il backup il giorno dell'installazione, funziona per qualche mese, poi qualcosa cambia - si riempie il disco, scade un token, viene aggiornato un pacchetto - e il backup smette di funzionare. Ma l'applicazione continua ad andare, nessuno guarda i log, e il problema viene scoperto solo quando serve un restore.
Le cause di fallimento che trovo più frequentemente, in ordine di probabilità:
Disco pieno. Il backup locale riempie la partizione prima di essere trasferito su storage remoto. Su un VPS con 800 GB di SSD e un database da 4 GB, il dump SQL compresso pesa circa 600 MB. Se la retention locale è 7 giorni, servono 4,2 GB solo per i dump del database, più lo spazio per i file dell'applicazione. Se lo storage locale si riempie per altre ragioni (log non ruotati, upload degli utenti, file temporanei), il backup fallisce con un errore di scrittura.
Credenziali di storage remoto scadute. Su S3, Wasabi, Backblaze B2 o qualunque object storage, le chiavi API possono scadere o essere revocate. Se il team cambia provider, aggiorna le credenziali dell'applicazione ma dimentica quelle del backup, il backup locale viene creato ma non trasferito - e quando il disco locale si riempie, anche quello smette di funzionare.
Timeout su database grandi. Il mysqldump che funzionava su un database da 500 MB al primo anno, al terzo anno lavora su un database da 8 GB e va in timeout. Il processo PHP che esegue backup:run ha un max_execution_time che non è stato aggiornato, o il backup viene eseguito nella stessa finestra di un processo batch notturno che blocca le tabelle.
Notification channel rotto. Sul gestionale marchigiano, il canale di notifica era un webhook Slack configurato dall'ex sviluppatore - che nel frattempo aveva lasciato l'azienda e il workspace Slack era stato riorganizzato. Il webhook restituiva 404, le notifiche di fallimento non arrivavano a nessuno, e il backup falliva in un silenzio totale.
Permessi filesystem cambiati. Dopo un aggiornamento del sistema operativo o un cambio di configurazione di PHP-FPM, l'utente www-data non ha più i permessi di scrittura sulla directory di backup. Il cron gira come www-data (tramite lo scheduler Laravel), il dump MySQL richiede lettura dal socket e scrittura su disco - se manca uno dei due permessi, il backup fallisce.
Lock non rilasciati. spatie/laravel-backup usa un lock per evitare esecuzioni concorrenti. Se un backup precedente è andato in timeout o è stato killato, il lock file rimane e tutti i backup successivi vengono saltati con il messaggio "Another backup process is already running." Un file bloccante di 0 byte che impedisce tutti i backup futuri fino a quando qualcuno non lo rimuove manualmente.
Stai cercando un Consulente Informatico esperto per mettere in sicurezza i backup della tua applicazione Laravel su VPS? Nel mio profilo professionale trovi l'esperienza concreta su backup, disaster recovery e gestione di VPS Hetzner, Contabo, OVH e Digital Ocean per PMI.
Diagnosi: capire cosa è andato storto e da quando
Prima di toccare qualunque configurazione, devo capire l'entità del danno - da quanto tempo il backup fallisce e qual è l'ultimo backup utilizzabile. I comandi diagnostici:
# Ultimo backup riuscito (se spatie/laravel-backup è configurato)
php artisan backup:list
# Log specifici del backup (gli ultimi 50 errori)
grep -i "backup" storage/logs/laravel.log | grep -i "error\|fail\|exception" | tail -50
# Spazio disco disponibile
df -h /
# Dimensione del database attuale
mysql -e "SELECT table_schema AS 'Database',
ROUND(SUM(data_length + index_length) / 1024 / 1024, 1) AS 'Size_MB'
FROM information_schema.tables
WHERE table_schema = 'gestionale'
GROUP BY table_schema;"
# Stato del cron (il backup è schedulato?)
crontab -l | grep -i artisan
# oppure, se usa lo scheduler Laravel:
php artisan schedule:list | grep backupSul gestionale marchigiano, backup:list mostrava l'ultimo backup di 92 giorni prima. Il log di Laravel conteneva 92 righe con BackupHasFailed - una per notte - ognuna con l'eccezione Could not create backup because: The backup temporary directory could not be created at /tmp/laravel-backup-temp. La causa: un job batch notturno di importazione dati scriveva file temporanei in /tmp senza pulirli, e dopo tre mesi /tmp era pieno. Il backup provava a creare la sua directory temporanea, falliva, e loggava l'errore. Nessuno leggeva il log. Il webhook Slack era rotto. L'alert email non era mai stato configurato.
Ripristino: rimettere in piedi la pipeline di backup
Il fix del problema immediato è stato banale: pulire /tmp e aggiungere un cron di pulizia per il job batch. Ma il vero intervento è stato ristrutturare l'intera pipeline di backup per renderla affidabile e verificabile.
La configurazione di spatie/laravel-backup che uso come baseline su ogni progetto Laravel in produzione:
// config/backup.php - sezione critica
'backup' => [
'name' => env('APP_NAME', 'laravel-backup'),
'source' => [
'databases' => ['mysql'],
'files' => [
'include' => [base_path()],
'exclude' => [
base_path('vendor'),
base_path('node_modules'),
storage_path('logs'),
storage_path('framework/cache'),
],
],
],
'destination' => [
'disks' => ['s3'], // MAI solo locale
],
'temporary_directory' => storage_path('app/backup-temp'),
],
'cleanup' => [
'strategy' => \Spatie\Backup\Tasks\Cleanup\Strategies\DefaultStrategy::class,
'default_strategy' => [
'keep_all_backups_for_days' => 7,
'keep_daily_backups_for_days' => 30,
'keep_weekly_backups_for_weeks' => 12,
'keep_monthly_backups_for_months' => 12,
'keep_yearly_backups_for_years' => 3,
'delete_oldest_backups_when_using_more_megabytes_than' => 5000,
],
],
'monitor_backups' => [
[
'name' => env('APP_NAME'),
'disks' => ['s3'],
'health_checks' => [
\Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumAgeInDays::class => 1,
\Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumStorageInMegabytes::class => 5000,
],
],
],La notifica è l'aspetto più importante di tutta la configurazione - più del backup stesso. Un backup che fallisce e genera un alert è un problema risolvibile in 10 minuti. Un backup che fallisce in silenzio per tre mesi è una catastrofe in attesa. La configurazione delle notifiche in config/backup.php deve coprire sia il successo che il fallimento, su almeno due canali indipendenti (email + Telegram, o email + Slack):
// config/backup.php - sezione notifications
'notifications' => [
'notifications' => [
\Spatie\Backup\Notifications\Notifications\BackupHasFailedNotification::class => ['mail', 'telegram'],
\Spatie\Backup\Notifications\Notifications\UnhealthyBackupWasFoundNotification::class => ['mail', 'telegram'],
\Spatie\Backup\Notifications\Notifications\BackupWasSuccessfulNotification::class => ['mail'],
],
'mail' => [
'to' => ['[email protected]', '[email protected]'],
],
],L'invio della notifica di successo via email (senza Telegram, per non generare rumore) serve come heartbeat: se non ricevi la mail di "backup riuscito" alle 2:30 di notte, qualcosa non va - e lo sai la mattina dopo, non tre mesi dopo. L'invio del fallimento su due canali è ridondanza: se la mail finisce in spam (cosa che succede), il Telegram arriva comunque.
Tre decisioni critiche in questa configurazione. Prima: destination.disks contiene solo s3, mai il disco locale. Un backup sullo stesso server che stai proteggendo non è un backup - è una copia che muore insieme all'originale se il disco si guasta o il server viene compromesso. Seconda: temporary_directory è in storage_path('app/backup-temp') anziché in /tmp, per evitare conflitti con altri processi che scrivono in /tmp. Terza: il monitor_backups.health_checks con MaximumAgeInDays => 1 fa scattare un alert se il backup più recente ha più di 24 ore - il che significa che un singolo fallimento notturno genera una notifica la mattina dopo, non tre mesi dopo.
La regola che nessuno rispetta: testare il restore
Un backup che non è mai stato testato con un restore non è un backup - è una speranza. La documentazione di Spatie su monitoring e health check dei backup copre il lato automatico, ma il test di restore è un'operazione che va fatta manualmente almeno una volta al mese.
Il pacchetto stefanzweifel/laravel-backup-restore è il complemento naturale di spatie/laravel-backup per automatizzare il restore in ambiente di staging:
# Restore dell'ultimo backup in un database di test
php artisan backup:restore --disk=s3 --backup=latest --database=mysql_staging
# Verifica integrità: conteggio record sulle tabelle principali
mysql -u staging_user staging_db -e "
SELECT 'orders' as tbl, COUNT(*) as cnt FROM orders
UNION ALL
SELECT 'customers', COUNT(*) FROM customers
UNION ALL
SELECT 'order_items', COUNT(*) FROM order_items;"Sul gestionale marchigiano, dopo aver ripristinato la pipeline, ho schedulato un test di restore mensile automatico che gira in un container Docker dedicato, ripristina il backup su un database temporaneo, verifica il conteggio delle tabelle principali, e se il risultato è ok cancella tutto. Se il restore fallisce o i conteggi sono anomali, parte un alert. Il costo computazionale è trascurabile - un dump da 4 GB si ripristina in meno di 3 minuti - e la certezza che il backup funziona davvero vale infinitamente di più di qualunque dashboard di monitoring.
Lo script che schedulo per il test mensile automatico:
#!/bin/bash
set -euo pipefail
# Scarica l'ultimo backup da S3
LATEST=$(aws s3 ls s3://backup-bucket/gestionale/ --recursive | sort | tail -1 | awk '{print $4}')
aws s3 cp "s3://backup-bucket/$LATEST" /tmp/restore-test.zip
# Restore in database temporaneo
unzip -o /tmp/restore-test.zip -d /tmp/restore-test/
DUMP=$(find /tmp/restore-test/ -name "*.sql" | head -1)
mysql -u restore_test restore_test_db < "$DUMP"
# Verifica conteggi
ORDERS=$(mysql -sN restore_test_db -e "SELECT COUNT(*) FROM orders")
CUSTOMERS=$(mysql -sN restore_test_db -e "SELECT COUNT(*) FROM customers")
if [ "$ORDERS" -lt 100 ] || [ "$CUSTOMERS" -lt 50 ]; then
curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d chat_id="${CHAT_ID}" \
-d text="RESTORE TEST FALLITO: orders=$ORDERS customers=$CUSTOMERS"
exit 1
fi
# Pulizia
mysql -e "DROP DATABASE restore_test_db; CREATE DATABASE restore_test_db;"
rm -rf /tmp/restore-test /tmp/restore-test.zip
echo "Restore test OK: orders=$ORDERS customers=$CUSTOMERS"Le soglie (100 ordini, 50 clienti) sono deliberatamente basse - non verificano che il backup sia completo al record, ma che non sia vuoto o corrotto. Un backup che si decomprime e si importa ma contiene zero record è tecnicamente "riuscito" ma operativamente inutile. Il check sui conteggi minimi cattura questo scenario.
Per chi vuole un quadro completo sulla strategia di backup a livello di VPS - non solo il livello applicativo ma anche filesystem, snapshot del provider e regola 3-2-1-1-0 - ho scritto due guide dedicate: una sulle strategie avanzate di backup per VPS Hetzner, OVH, Contabo, Digital Ocean e Aruba e una sul Disaster Recovery Plan con RPO/RTO verificabili che copre l'aspetto di compliance NIS2.
Il risultato sul gestionale marchigiano: dopo la ristrutturazione della pipeline, i backup funzionano ininterrottamente da cinque mesi, il test di restore mensile non ha mai fallito, e l'alert Telegram arriva ogni mattina alle 2:28 con "Backup riuscito - 612 MB su S3." Quando tre settimane dopo il fix un aggiornamento di Composer ha rotto una dipendenza di spatie/laravel-backup, l'alert di fallimento è arrivato alle 2:04 della stessa notte - e il fix è stato applicato la mattina dopo, prima che il titolare se ne accorgesse. La differenza tra tre mesi di backup silenziosamente falliti e un fix applicato in 8 ore è tutta nella configurazione del monitoring - non nel backup stesso.
Se la tua applicazione Laravel ha un backup configurato ma non hai mai verificato che funzioni - o se non sai dire con certezza quando è stato l'ultimo backup riuscito - hai un problema che non puoi permetterti di ignorare. Il momento peggiore per scoprire che il backup non funziona è quando ne hai bisogno. Contattami se vuoi un audit della tua pipeline di backup: in mezza giornata verifico lo stato dei backup, testo un restore su ambiente isolato, e configuro il monitoring per sapere immediatamente quando qualcosa smette di funzionare.