Deploy Laravel su Hetzner e OVH: come ho convertito un cliente dal venerdì del terrore al rollback in otto secondi con Deployer e GitHub Actions

Deploy Laravel su Hetzner e OVH: come ho convertito un cliente dal venerdì del terrore al rollback in otto secondi con Deployer e GitHub Actions

A marzo 2025 ho ricevuto una telefonata di un cliente lombardo del settore distribuzione industriale che gestisce un e-commerce B2B con circa 1,4 milioni di euro di fatturato annuo, costruito su Laravel 10 e ospitato su un dedicato Hetzner AX42. Il loro tech lead aveva fatto un deploy alle 17:40 del venerdì per pubblicare una piccola modifica al modulo di gestione degli ordini ricorrenti - niente di critico, una sola classe di servizio modificata. Il deploy era stato fatto come al solito: SSH al server come root, git pull, composer install --no-dev, riavvio di PHP-FPM. Alle 17:43 i primi clienti hanno iniziato a non riuscire più a fare login. Alle 17:46 il telefono del tech lead ha cominciato a squillare. Alle 18:03, dopo 23 minuti di e-commerce sostanzialmente fermo, il sistema era stato rimesso in piedi con un rollback fatto a mano via git reset --hard HEAD~1 seguito da un php artisan optimize:clear. Il problema era stato che il composer install aveva aggiornato una dipendenza che richiedeva un cache flush di Laravel, e nessuno se lo era ricordato. OPcache aveva memorizzato la vecchia versione del file di configurazione delle session, l'application stava leggendo la nuova versione del codice ma usando il vecchio config compilato, e il risultato erano sessioni non riconosciute e logout in cascata.

Quando il cliente mi ha chiamato la settimana dopo, voleva inizialmente una "checklist di deploy migliore". Gli ho proposto invece la cosa giusta: smettere di fare deploy manuali, punto. In due settimane di lavoro abbiamo introdotto Deployer come orchestratore, GitHub Actions come trigger automatico, una struttura di release atomiche con symlink, hook di pre-flight verificati, e un rollback testato che dura otto secondi netti. Da allora, sei mesi dopo, non hanno avuto un singolo incidente da deploy. Il vero ROI di questo cambiamento non è la velocità del rilascio (che pure è migliorata) - è il fatto che il loro tech lead non passa più il venerdì pomeriggio in stato di allerta, e che chiunque del team può fare un deploy senza paura. Questo articolo descrive il pattern operativo che applico in questi interventi, distillato da una decina di setup simili negli ultimi anni.

Anatomia di un disastro: il deploy manuale che ti fa perdere ventitré minuti

Voglio partire dall'incidente del cliente lombardo perché è esemplare di tutto quello che va storto in un deploy manuale, e perché ogni titolare PMI dovrebbe leggere il post-mortem come un memento. Il deploy manuale è un anti-pattern composto da una serie di azioni innocue prese singolarmente, ma che insieme creano un sistema fragile. SSH al server come root o come utente con sudo (perché "deve poter fare tutto"). git pull direttamente nella cartella di produzione (perché "il sito è già lì"). composer install eseguito sul server di produzione, scaricando dipendenze al momento del rilascio (perché "il vendor non lo committiamo"). Una serie di php artisan lanciati a mano, ognuno una potenziale fonte di errore. E in mezzo, il sito che è "metà vecchia versione, metà nuova versione" per quei dieci-quindici secondi in cui il deploy è in corso.

Nel caso del cliente lombardo, il bug specifico era il fatto che php artisan config:cache non era stato rilanciato dopo il composer install. Laravel mantiene una versione compilata del config in bootstrap/cache/config.php, e OPcache come descritto nella documentazione ufficiale dell'estensione su php.net la tiene in memoria del processo PHP-FPM. Senza un cache flush esplicito, l'applicazione gira con il vecchio config compilato anche se i file PHP sorgenti sono cambiati. La dipendenza aggiornata si aspettava una chiave di configurazione nuova, non la trovava (perché il config in cache era quello vecchio), e generava un'eccezione silente nella session middleware. Risultato: sessioni invalide, utenti disconnessi, login bloccato.

Il punto culturale che ripeto sempre ai clienti dopo un incidente di questo tipo è: il problema non è il bug specifico, è il fatto che il processo di deploy non aveva nessun gate per intercettarlo. Un deploy manuale è una sequenza di comandi sperati. Un deploy automatizzato è una pipeline di gate verificati. La differenza è enorme, e si vede tipicamente proprio nel momento in cui il deploy va storto - il manuale ti lascia in mezzo al guado, l'automatizzato si ferma da solo prima di causare danni. Questo è esattamente lo stesso principio che applico nel piano di consolidamento del debito tecnico nei 90 giorni post-subentro: senza processi automatizzati, ogni intervento sul sistema è una scommessa.

Deploy atomici: la struttura release/shared/current che cambia tutto

Il pattern fondamentale del deploy moderno, popolarizzato da Capistrano negli anni 2000 e poi adottato da Deployer per l'ecosistema PHP, è la struttura release-based con symlink atomico. Sul server, invece di avere una singola cartella /var/www/app modificata in-place a ogni deploy, hai questa struttura:

/var/www/app/
├── current -> /var/www/app/releases/20250311174032
├── releases/
│   ├── 20250311143015/
│   ├── 20250311165421/
│   └── 20250311174032/   ← release attiva
└── shared/
    ├── .env
    └── storage/

Il web server (Nginx o Apache) è configurato per servire da /var/www/app/current/public, che è un symbolic link verso una specifica release. Quando fai un deploy, Deployer crea una nuova cartella in releases/, ci clona dentro il codice da Git, lancia composer install --no-dev dentro quella cartella, esegue tutti i comandi php artisan necessari (config:cache, route:cache, view:cache, event:cache), monta i symlink verso shared/.env e shared/storage/, e - solo alla fine, e solo se tutto è andato bene - fa una singola operazione atomica: cambia il symlink current per puntare alla nuova release. Quel cambio di symlink è un'operazione di filesystem che dura nanosecondi. Non c'è momento "in mezzo" in cui il sito è metà vecchio metà nuovo. È vecchio, poi è nuovo. Stop.

I vantaggi di questa architettura sono tre. Primo, zero downtime: nessuna finestra di manutenzione necessaria, l'utente non si accorge di nulla. Secondo, rollback istantaneo: tornare alla versione precedente è solo un altro cambio di symlink, e Deployer lo fa con dep rollback in pochi secondi. Terzo, atomicità degli errori: se durante il composer install qualcosa va storto, la nuova release non viene mai promossa a current, e il sito continua a girare sulla vecchia release come se nulla fosse successo. Niente più "deploy a metà". Questo pattern è concettualmente vicino al Blue-Green Deployment formalizzato da Martin Fowler nel suo bliki, in versione semplificata e adatta a server PMI con un singolo nodo. Non hai bisogno di due server e di un load balancer - basta una struttura di cartelle ben pensata e un symlink.

Configurare Deployer su un Laravel reale: ricetta, hook, pre-flight check

Deployer è uno strumento PHP-nativo (quindi installabile via Composer come dev-dependency del progetto), agentless (non richiede installazione di nulla sul server target oltre a Git, PHP, Composer, SSH), e fornito con ricette pre-confezionate per i framework più diffusi. La ricetta ufficiale per Laravel è documentata sul sito di Deployer e copre già il 90% di quello che serve, ed è coerente con i principi della metodologia 12factor app sulla separazione netta di build, release e run. Il file deploy.php minimo per un Laravel su Hetzner è di una ventina di righe:

<?php
namespace Deployer;

require 'recipe/laravel.php';

set('application', 'app-cliente');
set('repository', '[email protected]:cliente/app.git');
set('keep_releases', 5);

add('shared_files', ['.env']);
add('shared_dirs', ['storage']);
add('writable_dirs', ['bootstrap/cache', 'storage']);

host('production')
    ->set('hostname', '88.99.x.y')
    ->set('remote_user', 'deployer')
    ->set('deploy_path', '/var/www/app')
    ->set('http_user', 'www-data');

after('deploy:failed', 'deploy:unlock');
after('deploy:symlink', 'artisan:queue:restart');

Le tre cose importanti da notare. Primo, l'utente remoto è deployer, non root - un utente dedicato senza privilegi sudo, con accesso SSH solo via chiave pubblica. Secondo, keep_releases=5 significa che Deployer mantiene le ultime 5 release sul server, permettendoti di rollback non solo all'ultima ma a qualunque delle 5 più recenti. Terzo, l'hook after('deploy:symlink', 'artisan:queue:restart') rilancia i worker delle code subito dopo lo switch del symlink, in modo che eseguano il codice nuovo (i worker delle code Laravel sono processi long-running che caricano il codice in memoria all'avvio).

Per intercettare il tipo di bug che ha fatto perdere 23 minuti al cliente lombardo, però, la ricetta di base non basta. Serve un layer di pre-flight check che verifichi che il deploy stia per andare bene prima di promuovere la nuova release. Quello che faccio sui clienti è aggiungere tre task custom: un task che lancia php artisan migrate --pretend (fa vedere quali migration verrebbero applicate senza eseguirle), un task che lancia un comando custom Artisan tipo health:check che testa i punti critici (connessione DB, connessione Redis, lettura di una chiave di config nuova), e un task che fa un curl HTTP verso un endpoint /health della nuova release prima dello switch del symlink. Se uno qualunque di questi tre fallisce, Deployer aborta il deploy e la vecchia release continua a servire il traffico. È esattamente il gate che mancava nel deploy manuale e che avrebbe intercettato il bug delle session prima che gli utenti se ne accorgessero. Come ho descritto nell'articolo dedicato all'ottimizzazione delle performance PHP su server Hetzner/OVH/Digital Ocean, il principio "misura prima di toccare" si applica con la stessa forza al deploy: verifica prima di promuovere.

GitHub Actions come trigger: dal push al deploy senza toccare il terminale

Avere Deployer configurato è già un grosso passo avanti, ma se il deploy è ancora qualcosa che il tech lead deve lanciare a mano da terminale, sei fermo a metà strada. Il passo successivo è automatizzare il trigger: chi e quando può lanciare un deploy. Lo strumento standard nel 2025, e quello che configuro su tutti i clienti che hanno GitHub come hosting del codice, è GitHub Actions - la piattaforma CI/CD integrata nativamente nel repository, ben documentata nella guida ufficiale di GitHub Actions su docs.github.com. Il workflow tipico ha tre stage: lint+test, build, deploy. Solo se i primi due stage passano, il terzo viene eseguito.

Il workflow YAML che imposto sui clienti Laravel è strutturato così: trigger su push verso main (o su tag) e su workflow_dispatch (che permette di lanciare deploy manualmente da UI), step di checkout, setup PHP della versione corretta, cache di Composer, composer install, esecuzione di php artisan test (Pest o PHPUnit), e - solo se tutto verde - esecuzione di dep deploy production con la chiave SSH del deploy iniettata da GitHub Secrets. La chiave SSH non vive mai in chiaro nel repository: è memorizzata in Settings → Secrets and variables → Actions come secret cifrato, e viene esposta al runner solo durante l'esecuzione del job, mai loggata. Questo è importante per la postura di sicurezza, perché la chiave SSH verso il server di produzione è una credenziale critica che, se finisse in chiaro in un commit o in un log pubblico, equivale a dare l'accesso al server al mondo intero.

Un dettaglio operativo importante: il branch main deve essere protetto con una branch protection rule che impedisca push diretti senza pull request, richieda almeno una review, e richieda che lo stato dei check CI sia verde. Questo trasforma il deploy in qualcosa che non può accadere senza che almeno due persone lo abbiano visto, eliminando la classe di errore "deploy del venerdì sera fatto al volo perché serviva subito". Su un team da tre-cinque sviluppatori PMI, questa è la singola misura culturale che fa più differenza per la stabilità della produzione, e si lega bene al principio di sviluppo sicuro che ho descritto nel mio articolo su come integrare DevSecOps nel flusso di sviluppo aziendale. Il deploy diventa un evento deliberato, non un riflesso reattivo.

Rollback in otto secondi: la cosa che ti salva quando il deploy diventa un incidente

L'ultima componente del setup, e la più importante in termini di tranquillità mentale, è il rollback. Tutti i sistemi di deploy vanno storto prima o poi. La domanda non è "se" ma "quando", e quello che conta è quanto tempo passa fra "ci siamo accorti che è andato storto" e "siamo tornati alla versione precedente". Su un deploy manuale, questo tempo è di tipicamente 10-30 minuti - il tempo di capire cosa è successo, di trovare il commit precedente, di fare un git reset, di rilanciare i comandi Artisan, di sperare che vada. Su Deployer, è di otto secondi: dep rollback production, e Deployer cambia il symlink current per farlo puntare alla penultima release in releases/. Il sito torna alla versione precedente in modo atomico, esattamente come era prima del deploy fallito.

L'unica cosa che richiede attenzione nei rollback Deployer sono le migration di database. Se il deploy che stai rollbackando ha già eseguito una migration distruttiva (ALTER TABLE che ha rinominato o droppato una colonna), il rollback del codice non può tornare indietro automaticamente sullo schema del database. La regola operativa che applico sui clienti è una sola: le migration di produzione devono essere sempre additive nelle prime fasi, mai distruttive in single-step. Se hai bisogno di rinominare una colonna email in email_address, lo fai in tre release distinte: prima aggiungi email_address e fai dual-write da entrambe le colonne, poi rilasci il codice che legge dalla nuova colonna, infine in una terza release droppi la vecchia colonna. Questa cadenza ti permette di rollbackare senza panico in qualunque momento perché lo schema del database resta backward-compatible. È la stessa filosofia che applico in tutti gli interventi di refactoring incrementale di codice PHP legacy in produzione: mai cambiamenti irreversibili in un singolo step, sempre transizioni multi-fase.

Dopo il rollback, è importante non fermarsi lì. Il rollback ti compra il tempo di capire cosa è andato storto senza la pressione di "il sito è giù". Quello che faccio sempre dopo un rollback è aprire un mini post-mortem in cui ricostruiamo cosa ha causato l'incidente, perché i pre-flight check non l'hanno intercettato, e che check aggiungere alla pipeline per evitare la stessa classe di errore in futuro. Questo è esattamente il loop di apprendimento descritto nel mio protocollo di incident response in 72 ore conforme NIS2 per le PMI italiane, applicato qui in versione meno drammatica: ogni rollback è una piccola lezione sul tuo processo, e se la perdi non lo migliori mai.

Se gestisci una PMI che fa ancora deploy manuali via FTP o SSH su applicazioni Laravel critiche in produzione, e ti riconosci nello scenario "il deploy del venerdì sera mi fa stare male", non è un problema solo tuo né solo del tuo team - è un problema strutturale di processo che si risolve con una decina di ore di lavoro mirato e che si ripaga al primo incidente evitato. Scopri come lavoro con i clienti sul tema della modernizzazione dei processi di deploy: in dieci anni di consulenza ho introdotto Deployer + GitHub Actions su decine di clienti Laravel su Hetzner, OVH e Digital Ocean, e il pattern è sempre lo stesso - due settimane di lavoro, zero incidenti da deploy nei sei mesi successivi, e un team che dorme la notte. Se vuoi una valutazione operativa del tuo flusso di deploy attuale con un piano di automazione progressiva calibrato sulla tua infrastruttura, contattami per una consulenza: in due giornate di lavoro tipicamente identifico i punti più fragili del processo manuale, configuro Deployer e GitHub Actions sul progetto, eseguo il primo deploy automatico end-to-end, testo il rollback in scenario reale, e ti consegno una runbook di gestione del flusso che il team può usare in autonomia da subito.

Ultima modifica: