Architettura Docker in produzione su Hetzner: dal Dockerfile al cluster

Architettura Docker in produzione su Hetzner: dal Dockerfile al cluster

Containerizzare un'applicazione per lo sviluppo e containerizzarla per la produzione sono due mestieri diversi, e confonderli è la causa della maggior parte dei deployment Docker che funzionano sul portatile e si piantano in produzione. Il Dockerfile che ti fa girare l'applicazione in locale, con tutto il toolchain a bordo, i sorgenti montati come volume e le credenziali in chiaro nell'ambiente, è perfetto per sviluppare e disastroso da esporre. In un progetto di migrazione infrastrutturale per una PMI mi capita spesso di prendere in carico applicazioni Laravel "già dockerizzate" che in realtà hanno solo un'immagine da sviluppo travestita da produzione: immagini da gigabyte, segreti incollati nel Dockerfile, nessun reverse proxy, nessuna strategia per quando il carico cresce. In questo articolo ricostruisco un'architettura Docker reale per portare un'applicazione PHP in produzione su Hetzner, partendo dal Dockerfile multi-stage, passando per la gestione dei segreti e il reverse proxy con TLS, fino alla transizione dalla singola VPS al piccolo cluster. Pattern da campo, con i tradeoff di costo e complessità dichiarati, non teoria da slide.

Perché un Dockerfile da sviluppo non va in produzione?

Perché ottimizza per cose opposte: lo sviluppo vuole comodità e velocità di iterazione, la produzione vuole leggerezza, riproducibilità e superficie d'attacco minima. Un'immagine da sviluppo porta a bordo l'intero toolchain, il compilatore, i pacchetti di build, gli strumenti di debug, spesso Composer e Node con tutte le dipendenze di sviluppo. Tutto questo in produzione è peso morto e, peggio, superficie d'attacco: ogni binario in più nell'immagine è qualcosa che un attaccante può sfruttare se riesce a entrare nel container. Un'immagine di produzione deve contenere il minimo indispensabile per far girare l'applicazione, e nient'altro.

C'è poi la questione della dimensione, che non è estetica. Un'immagine da un gigabyte è lenta da costruire, lenta da trasferire al registry, lenta da scaricare sul server al momento del deploy, e occupa spazio prezioso su una VPS. Un'immagine di produzione ben fatta per un'applicazione PHP sta in una frazione di quello spazio, e ogni deploy diventa più veloce e meno rischioso. La differenza non si ottiene con trucchi, si ottiene con una struttura precisa: separare la fase di costruzione dalla fase di esecuzione. È esattamente quello che fa un Dockerfile multi-stage.

Un ultimo punto riguarda la riproducibilità. Un'immagine di produzione deve essere un artefatto immutabile e versionato: la stessa immagine che hai testato in staging è bit per bit quella che va in produzione. Montare i sorgenti come volume, comodo in sviluppo, rompe questa garanzia, perché il codice che gira non è più quello dentro l'immagine. In produzione il codice vive dentro l'immagine, non accanto a essa, e questo è ciò che rende un rollback un'operazione banale: torni all'immagine precedente, e hai esattamente lo stato di prima.

Come si costruisce un Dockerfile multi-stage per PHP?

Si separano due fasi: uno stage di build che ha tutto il toolchain e produce gli artefatti, e uno stage di runtime che parte da un'immagine minimale e copia solo ciò che serve a eseguire. Lo stage di build installa le dipendenze con Composer, compila gli asset se servono, e prepara l'applicazione. Lo stage di runtime parte pulito e copia dal primo solo il codice e le dipendenze già risolte. Il toolchain di build resta fuori dall'immagine finale.

# Stage 1: build
FROM php:8.4-cli AS build
WORKDIR /app
RUN apt-get update && apt-get install -y git unzip libzip-dev \
    && docker-php-ext-install zip pdo_mysql opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative

# Stage 2: runtime
FROM php:8.4-fpm-alpine AS runtime
WORKDIR /var/www
RUN docker-php-ext-install pdo_mysql opcache
COPY --from=build /app /var/www
RUN chown -R www-data:www-data storage bootstrap/cache
USER www-data

La logica è che lo stage build può essere grasso quanto serve, perché viene scartato: nell'immagine finale finisce solo il risultato di COPY --from=build. Lo stage runtime parte da php-fpm-alpine, una base minimale, abilita solo le estensioni che l'applicazione usa davvero, e gira come utente non privilegiato (www-data), mai come root. Quel USER www-data finale è una delle righe più importanti del file: un processo che gira come root dentro un container è un'escalation che aspetta di succedere, e farlo girare come utente normale è una difesa elementare che troppe immagini saltano. Le versioni vanno verificate: PHP 8.4 è in supporto attivo, e per un'applicazione Laravel attuale è la base giusta secondo la documentazione ufficiale di Laravel. Ho approfondito il versante sicurezza di queste immagini nella guida all'hardening dei container Docker per Laravel e Symfony.

Come gestisco i segreti senza metterli nell'immagine?

Tenendoli fuori dall'immagine e dal codice sorgente, e iniettandoli a runtime come variabili d'ambiente o file montati. È l'errore di sicurezza più comune e più grave nei deployment Docker: la chiave del database, il token dell'API, la APP_KEY di Laravel incollati nel Dockerfile o committati nel .env dentro il repository. Un segreto dentro l'immagine è un segreto dentro ogni copia di quell'immagine, nel registry, nella cache di build, nella cronologia: una volta lì, è compromesso, e ruotarlo significa ricostruire e ridistribuire tutto.

La regola è netta, e vale la pena fissarla perché è controintuitiva.

Un'immagine Docker va progettata come se fosse pubblica, anche quando è in un registry privato. Tutto ciò che metti dentro l'immagine, lo stai distribuendo: i segreti non vanno nell'immagine perché l'immagine non è il posto dei segreti, è il posto del codice.

L'immagine è pubblica anche quando è privata, cioè va trattata come se chiunque potesse leggerla, perché prima o poi qualcuno con accesso al registry o al server la leggerà. I segreti si iniettano dall'esterno al momento dell'avvio del container. Su una singola VPS, il meccanismo pratico è un file .env che vive sul server, fuori dal repository e fuori dall'immagine, con permessi ristretti, passato al container come env-file. Su un'orchestrazione più strutturata, esistono i Docker secrets, che montano il segreto come file in memoria dentro il container senza farlo passare dalle variabili d'ambiente, che sono più facili da esfiltrare per errore (finiscono nei log, nei dump di processo, negli ispezionamenti del container).

Il principio operativo che applico è la separazione netta tra configurazione e segreti. La configurazione non sensibile (l'ambiente, i nomi degli host, i flag di feature) può stare nell'immagine o in file di configurazione versionati; i segreti veri (password, chiavi, token) non toccano mai né l'immagine né il repository, e vivono solo sul server di destinazione, idealmente in un gestore di segreti dedicato man mano che l'infrastruttura cresce. Questa disciplina è ciò che rende possibile ruotare un segreto compromesso in minuti, sostituendo il file sul server e riavviando il container, invece che in giorni.

Come metto un reverse proxy con TLS davanti ai container?

Si mette un reverse proxy che termina il TLS e instrada il traffico ai container, così l'applicazione non deve occuparsi di certificati né essere esposta direttamente. Un container PHP-FPM non parla HTTP da solo, e non dovrebbe mai essere esposto direttamente a Internet. Davanti ci va un web server, tipicamente Nginx, e davanti ancora un reverse proxy che gestisce il TLS, i certificati e l'instradamento. Per un deployment moderno, strumenti come Traefik o Caddy automatizzano la parte più noiosa: ottengono e rinnovano i certificati Let's Encrypt da soli, instradano in base al dominio, e si configurano in modo dichiarativo.

L'architettura tipica su una singola VPS Hetzner è questa: il reverse proxy ascolta sulle porte 80 e 443, ottiene i certificati TLS automaticamente, e inoltra le richieste al container Nginx, che a sua volta passa le richieste PHP al container FPM via socket o porta interna. I container dell'applicazione non espongono porte verso l'esterno: comunicano solo sulla rete interna di Docker, e l'unico punto di contatto con Internet è il reverse proxy. Questo riduce drasticamente la superficie d'attacco: c'è una sola porta d'ingresso, presidiata, e tutto il resto è invisibile dall'esterno.

Il vantaggio operativo del TLS automatico non va sottovalutato: i certificati Let's Encrypt scadono ogni novanta giorni, e un certificato scaduto è un sito giù. Automatizzarne il rinnovo elimina una delle cause più banali e più frequenti di disservizio, quella del certificato che nessuno si era ricordato di rinnovare. Configurare la catena reverse-proxy, Nginx e FPM in modo pulito è anche ciò che permette di aggiungere domini e applicazioni sullo stesso server senza rifare l'impianto ogni volta. Se vuoi vedere come imposto questo tipo di infrastruttura su misura del progetto, nel mio profilo professionale trovi l'esperienza concreta su deployment Docker e hardening Linux per le PMI.

Quando passo dalla singola VPS al cluster?

Quando una sola macchina non basta più, per capacità o per resilienza, e non un minuto prima, perché il cluster aggiunge complessità che va giustificata. La singola VPS con Docker Compose è un'architettura perfettamente valida per la stragrande maggioranza delle applicazioni PMI: è semplice, economica, comprensibile, e un singolo server Hetzner ben dimensionato regge carichi sorprendentemente alti. Introdurre un cluster prima di averne bisogno è il classico over-engineering che paghi in complessità operativa senza un beneficio reale.

I due segnali che giustificano il salto sono precisi. Il primo è la capacità: quando un singolo server, anche salendo di taglio, non regge più il carico, e hai bisogno di distribuire le richieste su più macchine. Il secondo, spesso più importante per un business, è la resilienza: quando non puoi più permetterti che il guasto di una singola macchina mandi giù il servizio, e ti serve che un altro nodo prenda il carico automaticamente. Finché nessuno di questi due segnali è presente, la singola VPS è la scelta giusta, e la guida ai pattern e anti-pattern di Docker Compose in produzione copre come farla bene.

Quando il salto serve davvero, la domanda diventa quale cluster. E qui la risposta onesta per una PMI è quasi sempre "il più semplice che risolve il tuo problema", non Kubernetes per default. Docker Swarm, integrato in Docker, permette di orchestrare più nodi con una curva di apprendimento bassa e copre bene i casi di un piccolo cluster: replica dei servizi, failover di base, rolling update. Kubernetes è enormemente più potente, ma porta una complessità operativa che per un piccolo cluster è sproporzionata, e che richiede competenze che una PMI raramente ha in casa. Scegliere Kubernetes "perché è lo standard" quando bastano due o tre nodi Swarm è il modo più rapido per trasformare un'infrastruttura gestibile in un sistema che nessuno sa più amministrare.

Quali sono i tradeoff di costo e complessità?

Il tradeoff fondamentale è che ogni passo verso la resilienza costa in denaro e in competenza, e va comprato solo quando il rischio che elimina vale più del suo prezzo. Una singola VPS è economica e semplice, ma ha un singolo punto di guasto: se quella macchina cade, il servizio cade. Un piccolo cluster di due o tre nodi elimina quel punto di guasto, ma triplica il costo dell'infrastruttura e aggiunge la complessità dell'orchestrazione, del bilanciamento del carico, dello storage condiviso o replicato. Kubernetes elimina ancora più classi di guasto e scala quasi all'infinito, ma a un costo di complessità che per la maggior parte delle PMI supera il beneficio.

La scelta razionale parte dal valore della continuità per quel business specifico. Per un'applicazione interna usata in orario d'ufficio, un'ora di fermo annuale è un fastidio, e la singola VPS con buoni backup è più che adeguata. Per un e-commerce nel picco stagionale, un'ora di fermo sono ordini persi e clienti irritati, e il costo di un piccolo cluster ridondante si ripaga al primo guasto evitato. Il numero da mettere sul tavolo non è "quanto costa il cluster", è "quanto costa un'ora di fermo per la mia azienda", e da lì si decide quanta ridondanza comprare.

Il filo che attraversa tutto questo è che un'architettura Docker di produzione non è un insieme di tecnologie alla moda da accumulare, è una serie di decisioni proporzionate al rischio e al carico reali. Il Dockerfile multi-stage per immagini leggere e sicure è sempre giusto, perché non costa nulla in complessità e fa solo bene. La gestione corretta dei segreti è sempre obbligatoria, perché l'alternativa è un'esposizione che prima o poi si paga. Il reverse proxy con TLS automatico è quasi sempre la scelta giusta, perché toglie una classe di problemi banali. Il cluster, invece, è una decisione da prendere sui numeri, non sulla moda: si compra quando la continuità del business lo richiede, e non prima. Se vuoi una mano a progettare l'architettura Docker giusta per il tuo carico, dimensionando la complessità sul rischio reale invece che sull'ultima tendenza, contattami per una consulenza diretta: partiamo da cosa devi far girare e da quanto ti costa fermarti, e costruiamo solo quello che serve.

Ultima modifica: