Laravel in maintenance mode da tre mesi su Contabo VPS L: come ho riportato online un e-commerce HoReCa bolognese in quattro giorni tra migration orfane, storage saturo e code bloccate
Perché un sito Laravel può restare in maintenance mode per mesi senza che nessuno se ne accorga davvero?
Il 19 giugno 2025 ho ricevuto una chiamata dal titolare di una piccola società bolognese che importa e distribuisce specialty food italiano alla ristorazione B2B: caffè di torrefazioni artigianali, olive ascolane, paste di Gragnano, conserve di tonno siciliane. L'e-commerce era il canale principale per i loro clienti HoReCa, con ordini medi intorno ai 350 euro e un fatturato mensile che prima della crisi si attestava intorno ai 47.000 euro. Il sito era fermo dal 25 marzo 2025 - quasi tre mesi pieni - mostrando una pagina "Siamo in manutenzione, torniamo presto" con il logo aziendale e un contatto email generico. Il titolare lo sapeva, ovviamente, ma aveva creduto fino a giugno che lo sviluppatore lo stesse sistemando, e quando ha scoperto che quello sviluppatore non rispondeva più da metà aprile si è reso conto che non sapeva assolutamente cosa fosse successo tecnicamente, cosa ci fosse da sistemare, o a chi rivolgersi.
L'infrastruttura era un Contabo VPS L tedesco (8 vCPU AMD, 30 GB RAM, 400 GB NVMe), scelto quattro anni prima dal precedente sviluppatore per rapporto qualità/prezzo - scelta discutibile sul piano della performance ma funzionale per il carico reale dell'e-commerce. Lo stack era Laravel 9.52 con PHP 8.1, MySQL 8.0 locale, Redis 6 per cache e code, Nginx come reverse proxy, Vue 3 come frontend SPA che consumava le API REST del backend Laravel. I clienti del titolare - ristoratori e chef bolognesi, toscani, emiliani - nei primi mesi avevano chiamato in negozio per fare ordini via telefono, poi molti avevano iniziato a comprare altrove perché il telefono era troppo lento per ordinare un listino da 400 SKU. Il titolare stimava una perdita di fatturato di almeno 110.000 euro in tre mesi, più il danno reputazionale difficile da quantificare.
In quattro giorni ho ricostruito lo stato consistente dell'applicazione, riportato il sito online, rigenerato gli indici del catalogo prodotti, riattivato il flusso di ordini e consegnato al titolare un runbook base per gestire autonomamente le emergenze più comuni. In questo articolo racconto la diagnosi passo passo, perché lo scenario "maintenance mode dimenticato" è uno dei più frustranti da gestire quando il team originario è irreperibile: non c'è un problema evidente da riparare, c'è una catena di problemi dormienti che si sono accumulati e bisogna srotolarla senza fare danni ulteriori.
Stai cercando un Consulente Laravel per gestire un'emergenza su e-commerce B2B fermo in maintenance mode? Nel mio profilo professionale trovi l'esperienza concreta su stack Laravel in produzione, recupero di deploy falliti e gestione di crisi operative. Contattami per una consulenza diretta.
Prima mattinata: capire cosa è davvero rotto prima di uscire dalla maintenance mode
La tentazione, quando arrivi su un sito in maintenance mode da mesi, è lanciare php artisan up e vedere cosa succede. È un istinto sbagliato e pericoloso: Laravel entra in maintenance mode per un motivo, e quando qualcuno lo ha lasciato dentro per tre mesi quel motivo è quasi sempre un deploy abortito a metà - con schema database in stato intermedio, cache disallineata, queue in stato indefinito, storage pieno di file temporanei mai cancellati. Eseguire artisan up in queste condizioni significa portare l'applicazione online in uno stato inconsistente, che peggiora i danni invece di risolverli.
La prima cosa che ho fatto, dopo aver confermato al titolare di avere il permesso scritto di intervenire (sempre via email esplicita, mai via telefono soltanto, per avere traccia della delega), è stata loggarmi come utente deploy sul VPS usando le credenziali che il titolare aveva salvato in un foglio Excel del 2021. Gli utenti SSH del server non erano stati ruotati dopo la sparizione del dev, e questo è stato un regalo involontario ma comodo. Appena dentro, il primo ciclo di comandi che ho lanciato è stato di sola osservazione:
cd /var/www/ecommerce
php artisan --version
ls -la storage/framework/
ls -la storage/framework/down
cat storage/framework/down 2>/dev/null || echo "no down file"
php artisan about | head -40
php artisan migrate:status
git -C /var/www/ecommerce log --oneline -20
git -C /var/www/ecommerce status
df -h
du -sh /var/www/ecommerce/storage/*
du -sh /var/www/ecommerce/bootstrap/cache
du -sh /var/log/nginx /var/log/mysql /var/log/php8.1-fpm.logLa risposta mi ha dato in cinque minuti un quadro piuttosto chiaro della situazione. Il file storage/framework/down esisteva ed era datato 25 marzo 2025 - il giorno esatto in cui il sito era andato in manutenzione. Conteneva un JSON con una secret di bypass standard di Laravel ma nessun template personalizzato (il titolare aveva poi configurato la pagina "Torniamo presto" via Nginx, non via Laravel). artisan migrate:status mostrava 187 migration totali, di cui le ultime 3 in stato "pending" - le migration del deploy fallito. git log mostrava un commit del 25 marzo 2025 alle 10:47 con messaggio "WIP: refactor ordini con stored procedure" non ancora pushato al remote, e git status riportava 12 file modificati non committati, tra cui app/Services/OrderProcessor.php, due app/Models/ e un file .env.local creato ad hoc per testing. Il disco era all'83% pieno sul mount principale, con /var/www/ecommerce/storage/logs/ che pesava 38 GB (un solo laravel.log datato 24 marzo 2025, che conteneva tre mesi di rotazione fallita prima dello stop) e storage/framework/cache/data/ che pesava 12 GB di cache dati non più ripulita.
Lo stato era quindi questo, nitido e ricostruibile: il precedente sviluppatore stava facendo un refactor del flusso ordini spostando logica in stored procedure MySQL, aveva avviato il deploy alle 10:47 del 25 marzo, aveva applicato due migration ma la terza era fallita (probabilmente per un errore di sintassi della stored procedure su una delle tre migration), e aveva lasciato tutto in maintenance mode pensando di tornarci più tardi. Poi - e questo lo apprenderò dal titolare successivamente - quello sviluppatore aveva avuto un problema personale serio a metà aprile che l'aveva allontanato per settimane, durante le quali non aveva più risposto a email o telefonate.
Tre migration orfane: applicare il rollback senza rompere il database
Il nodo tecnico critico era lo stato del database. Applicare php artisan up adesso avrebbe riportato il sito online con il codice vecchio (il commit non pushato era in locale ma il codice in production era precedente) che cercava di parlare con uno schema database parzialmente migrato. Un disastro sicuro. La scelta strategica era tra due opzioni: A) committare il codice WIP orfano, pushare tutto, applicare le migration mancanti, pregare che il refactor funzioni; oppure B) fare il rollback delle migration già applicate, ripristinare lo schema al 24 marzo, ripristinare il codice a prima del WIP, e riportare tutto alla sera del 24 marzo come se il deploy del 25 non fosse mai accaduto. Ho scelto B senza esitazioni, perché il codice del refactor non era né testato né revisionato, e avrei rischiato di propagare un bug in produzione durante un recupero d'emergenza - contraddicendo il principio base che in emergenza non si introducono modifiche, si riporta allo stato ultimo noto come funzionante.
Per fare il rollback pulito ho dovuto prima assicurarmi che le due migration già applicate fossero davvero reversibili. Non tutte lo sono - migration distruttive come DROP COLUMN o DROP TABLE richiedono attenzione perché il metodo down() a volte è scritto male o mancante del tutto. Ho letto le due migration fisiche nei file database/migrations/2025_03_24_*.php e ho verificato che fossero entrambe reversibili (aggiungevano colonne nuove e una stored procedure, tutte operazioni reversibili con DROP COLUMN e DROP PROCEDURE). Prima di toccare il database, ho fatto un dump completo con mysqldump --single-transaction --routines --triggers --events ecommerce > /root/backup-20250619.sql (168 MB compressi), verificato l'integrità del dump con file e con un quick test di restore su un database temporaneo ecommerce_test, e solo dopo ho lanciato il rollback controllato:
cd /var/www/ecommerce
php artisan migrate:rollback --step=2 --pretend
php artisan migrate:rollback --step=2
php artisan migrate:status
git stash save "WIP orfano 25 marzo 2025 - non applicare"
git log --oneline -5
git checkout HEAD -- app/Services/OrderProcessor.php app/ModelsIl --pretend è fondamentale: mostra il SQL che verrebbe eseguito senza applicarlo. Mi ha confermato che il rollback avrebbe eseguito ALTER TABLE orders DROP COLUMN processing_metadata e DROP PROCEDURE calculate_order_total, entrambe operazioni pulite. L'esecuzione reale è durata 2,3 secondi. Il git stash ha preservato il codice WIP in un'area sicura per eventuale recupero futuro - non l'ho cancellato perché il titolare potrebbe voler riprendere il refactor con un nuovo sviluppatore e recuperare almeno il diff. Il git checkout HEAD -- ... ha ripristinato i file modificati allo stato del commit corrente in locale.
Pulizia storage e cache: sbloccare il disco prima di riaprire il sito
Con il database consistente, il passo successivo era liberare spazio disco e ripulire le cache. 83% di riempimento su un VPS è un territorio pericoloso perché molti processi Laravel e MySQL falliscono silenziosamente sotto soglia - MySQL ad esempio va in read-only quando scende sotto i 2 GB liberi. Il colpevole principale erano i 38 GB di laravel.log: un singolo file enorme, con tre mesi di errori ripetuti della cronologia pre-deploy che probabilmente era già in stato degradato da settimane. Ho archiviato il file con gzip (che lo ha ridotto a 2,1 GB perché i log Laravel comprimono molto bene), l'ho spostato in /root/archive/laravel-log-pre-manutenzione.log.gz come evidenza per un eventuale audit successivo, e ho ricreato un nuovo file vuoto con i permessi corretti. Poi ho ripulito le cache Laravel obsolete con i comandi standard php artisan cache:clear, php artisan config:clear, php artisan view:clear, php artisan route:clear, e ho svuotato manualmente storage/framework/cache/data/ e bootstrap/cache/ lasciando intatti solo i file .gitignore.
Questa pulizia ha liberato 48 GB e portato il disco al 71%, ma c'era un altro problema meno evidente: MySQL aveva circa 12 GB di binary log non ruotati (/var/lib/mysql/mysql-bin.*). Ho verificato che la replica non fosse attiva (SHOW SLAVE HOSTS vuoto) e ho lanciato PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 7 DAY) per liberare quello spazio. Dopo queste operazioni il disco era al 52%, uno stato molto più sano, e ho contestualmente configurato logrotate per /var/www/ecommerce/storage/logs/ (che non era mai stato impostato prima) e per i binary log MySQL. L'esperienza di questo tipo di situazioni l'ho già raccontata nell'approfondimento su come risolvere problemi di spazio disco su VPS Debian/Ubuntu, perché si ripresenta praticamente identica in ogni VPS abbandonato a se stesso per qualche mese.
Uscire dalla maintenance mode: verifica, test, warmup cache
Il passo finale del pomeriggio del secondo giorno è stato la riapertura controllata del sito. Non php artisan up direttamente: prima volevo verificare che tutto il circuito applicativo fosse sano. Ho attivato la secret di bypass della maintenance mode Laravel così da poter navigare il sito come se fosse online pur mantenendolo inaccessibile al pubblico, e ho fatto un giro completo del catalogo: homepage, categoria, scheda prodotto, login area riservata cliente, carrello, pagina carrello vuoto, pagina checkout (senza completare l'ordine). Tutti i pezzi rispondevano correttamente. L'unico problema che ho trovato era che il Redis aveva session data scadute e job zombie nelle code, che ho ripulito con redis-cli mirato (DEL laravel_cache:*, flush della coda di default con DEL queues:default) dopo essermi accertato con il titolare che nessuno di quei job fosse più rilevante a tre mesi di distanza.
Per il warmup cache ho ricreato le cache applicative con i comandi standard php artisan config:cache, php artisan route:cache, php artisan view:cache, php artisan event:cache, e ho verificato il funzionamento di Horizon-like queue workers (in questo progetto le code erano gestite direttamente da un cron php artisan queue:work piuttosto che da Horizon). Solo a quel punto, intorno alle 17:30 del 20 giugno, ho rimosso il file storage/framework/down con php artisan up e il sito è tornato online dopo 86 giorni di fermo. Ho monitorato laravel.log e error.log di Nginx per la prima mezz'ora per cogliere eventuali errori residui - c'erano alcuni 404 su asset CDN vecchi e qualche sessione PHP corrotta in cache, risolti con un restart pulito di PHP-FPM e Redis. Alle 18:30 il sito era operativo, il titolare aveva fatto un ordine di prova, e io ho mandato l'email di conferma al cliente.
I tre giorni successivi: stabilizzazione, documentazione, setup monitoring
I giorni dal 21 al 23 giugno li abbiamo dedicati a stabilizzare il VPS e a costruire la documentazione operativa, perché un sito riportato online senza osservabilità è solo un'emergenza rimandata. Ho impostato un monitoring minimo sul VPS con alert di base che inviasse email al titolare in tre casi: disco sopra l'85%, sito HTTP non raggiungibile per più di due minuti consecutivi, errore 500 Internal Server Error più di dieci volte in cinque minuti. Niente di sofisticato: monit locale più un Uptime Kuma su un piccolo droplet separato da 4 euro al mese. Volutamente niente Prometheus e Grafana in questa fase, perché il titolare non aveva team tecnico e non avrebbe saputo gestire dashboard complesse: meglio tre alert email chiari e comprensibili che cento metriche grafiche che nessuno guarda mai.
Il runbook consegnato al titolare conteneva quattro sezioni fondamentali in linguaggio non tecnico: "Cosa fare se il sito è lento" (controllare il panel Uptime Kuma, contattarmi se rosso da più di 10 minuti), "Cosa fare se ricevi un alert di disco pieno" (contattarmi subito, non cancellare file a caso), "Cosa fare se un cliente segnala un errore" (raccogliere screenshot dell'errore e URL esatto, inoltrare a me), "Cosa NON fare mai" (non premere pulsanti nel Contabo panel di cui non conosci l'effetto, non modificare file .env, non riavviare il server). Questa ultima sezione è la più importante nei runbook per clienti non tecnici: la catastrofe di questi sei mesi era nata proprio perché il titolare non sapeva cosa fosse lecito fare e cosa no, quindi ha finito col non fare niente. Dargli una lista esplicita di "cose che puoi fare in autonomia" e "cose per cui devi chiamarmi" gli ha restituito controllo senza esporlo a rischi di rompere ulteriormente.
In parallelo ho preparato un audit del debito tecnico residuo: il codice Laravel 9.52 andava aggiornato almeno a Laravel 10.x LTS entro fine anno (Laravel 9 ha ricevuto l'ultimo security patch ad agosto 2024, vedi policy ufficiale del ciclo di rilascio), il refactor orfano del 25 marzo andava ripreso con un approccio diverso (senza stored procedure, in puro PHP eloquente), e il deploy andava automatizzato per evitare che il prossimo sviluppatore potesse lasciare un deploy a metà per qualsiasi motivo. Di automazione deploy ho già parlato nell'approfondimento su deploy Laravel automatizzato con Deployer e GitHub Actions, perché è la procedura che adotto sistematicamente in questi casi per rendere i deploy atomici e reversibili.
Lezione finale: cosa cambia tra un deploy fallito con e senza un manutentore presente
Questa esperienza conferma una verità scomoda che ho osservato in almeno otto subentri analoghi negli ultimi tre anni: la differenza tra un deploy Laravel fallito che si risolve in trenta minuti e uno che tiene il sito fermo per tre mesi non è tecnica, è organizzativa. I comandi per fare rollback di due migration e ripristinare uno schema consistente sono gli stessi in entrambi i casi. Quello che cambia è la presenza di un responsabile tecnico attento a cosa sta succedendo sulla produzione in un dato momento. Se un altro sviluppatore nel team avesse visto il 26 marzo che il sito era ancora in maintenance mode, avrebbe fatto la diagnosi in mezz'ora e applicato il rollback nel pomeriggio. Senza nessun manutentore presente, il sito è rimasto in quel limbo finché il titolare non ha capito di doversi rivolgere altrove.
La lezione operativa per chi gestisce un e-commerce Laravel in produzione con un singolo sviluppatore freelance è questa: pretendere che le sessioni di deploy siano tracciate (un log di deploy condiviso via Slack o email è già meglio di niente), pretendere un handover formale quando il rapporto con lo sviluppatore si interrompe per qualsiasi motivo (anche temporaneamente, per malattia o ferie lunghe), e avere almeno un secondo contatto tecnico di riserva a cui rivolgersi in emergenza. In alternativa, costruire fin da subito un piano di disaster recovery operativo che contempli lo scenario "il mio sviluppatore non risponde da 48 ore" e definisca chi chiamare. Per il lato specifico della gestione del subentro su progetti Laravel e PHP legacy, vedi anche il mio approfondimento su subentro senza sviluppatore manutentore su codice PHP legacy.
Se gestisci un e-commerce o un'applicazione web Laravel in produzione su Hetzner, OVH, Contabo, Aruba o altro VPS unmanaged e ti trovi con il sito in maintenance mode, deploy abortito a metà o uno sviluppatore che non risponde, contattami direttamente. Posso diagnosticare lo stato reale dell'applicazione, ricostruire la consistenza di database e filesystem, riportare il sito online in sicurezza e impostare le procedure operative perché non ti ritrovi più in questa situazione. Nel mio profilo professionale trovi l'esperienza concreta su recupero di e-commerce Laravel fermi e gestione di crisi operative su infrastruttura PHP.