Docker Compose in produzione su VPS: pattern corretti e anti-pattern da evitare
Perché Docker Compose in produzione resta una scelta legittima per le PMI italiane nel 2026
Il 14 luglio 2025 mi ha contattato d'urgenza un consulente commerciale di un'azienda bresciana del settore distribuzione di prodotti industriali - circa 3,8 milioni di euro di fatturato annuo, 22 dipendenti, un gestionale web proprietario per la gestione di ordini e fornitori scritto in PHP 8.2 con Symfony 6.4. L'infrastruttura era un singolo VPS Hetzner CX41 (4 vCPU, 16 GB RAM, 160 GB SSD) con tutto lo stack applicativo containerizzato e orchestrato da Docker Compose v2.24 - una scelta architetturale fatta tre anni prima dal precedente sviluppatore interno, che nel frattempo aveva cambiato azienda dopo aver lasciato una documentazione operativa praticamente inesistente. Il problema era grave: la mattina del 13 luglio il server si era riavviato per un aggiornamento automatico di sicurezza del kernel configurato via unattended-upgrades di Debian, e dopo il riavvio il gestionale non era più tornato su. Due container - quello del database MariaDB e quello del worker asincrono delle code Symfony Messenger - erano rimasti in stato Exited e non si riavviavano. Il database mostrava un errore di corruzione dei file InnoDB che nessuno nel team dell'azienda sapeva interpretare. Al momento della chiamata erano passate 26 ore dall'incidente, il gestionale era offline, le operatrici dell'ufficio ordini stavano facendo tutto a mano su carta, e il titolare stimava una perdita di circa 18.000 euro di fatturato per ogni giornata ulteriore di indisponibilità.
In 11 ore di lavoro continuato, distribuite fra il pomeriggio del 14 luglio e la mattina del 15, abbiamo ripristinato il gestionale partendo da un backup InnoDB parziale che fortunatamente il team dell'azienda aveva su un NAS interno (senza avere la minima idea che fosse lì, finché non me l'ha mostrato la segretaria del titolare dopo una domanda casuale). Ma la vera domanda strategica che ho dovuto affrontare con il titolare nei giorni successivi è stata un'altra: la scelta di Docker Compose in produzione su un VPS unico era stata uno sbaglio fin dall'inizio, o era una scelta ragionevole che era stata implementata male? La risposta, dopo un audit completo della configurazione, è che Docker Compose in produzione su VPS singolo è una scelta legittima e perfettamente sostenibile per una PMI italiana di questa dimensione - a patto di implementarla rispettando alcuni pattern precisi e di evitare una serie di anti-pattern specifici che, nel caso di questo cliente, erano stati tutti rispettati nella loro versione peggiore dal vecchio sviluppatore.
Questo articolo è il distillato operativo dei pattern che applico quando disegno o sistemo infrastrutture Docker Compose in produzione per PMI italiane, basato sull'esperienza di circa 30 interventi simili negli ultimi quattro anni. Il principio guida è uno: Docker Compose non è una versione ridotta di Kubernetes, è uno strumento architettonicamente diverso con un target applicativo diverso. Usato nel suo dominio di applicabilità - un singolo host fisico o virtuale, uno stack applicativo ragionevolmente stabile nel tempo, un team tecnico senza competenze DevOps strutturate - Docker Compose è una delle scelte più pragmatiche e cost-effective che una PMI italiana possa fare oggi per la propria infrastruttura. Usato fuori da quel dominio - orchestrazione multi-host, scaling orizzontale frequente, sostituzioni frequenti di servizi in catalogo - genera più problemi di quanti ne risolva. La differenza la fa conoscere il confine e progettare di conseguenza.
Se stai pianificando un'infrastruttura Docker Compose in produzione o stai ereditando una configurazione esistente e vuoi una valutazione tecnica indipendente, nel mio profilo professionale trovi il dettaglio dei progetti di infrastruttura containerizzata che ho consolidato in contesti PMI italiane, con approccio di intervento incrementale e senza la riscrittura forzata verso Kubernetes che spesso viene proposta come unica soluzione.
Anti-pattern 1: restart policy mancante o sbagliata - la causa più comune di downtime prolungato
L'anti-pattern che ho trovato nel 72% dei subentri Docker Compose a cui ho lavorato - percentuale misurata esattamente su 25 progetti degli ultimi tre anni - è l'assenza completa o l'errata configurazione della restart policy nei service del file compose.yaml. La restart policy è la regola che dice a Docker cosa fare quando un container esce dal suo stato attivo: se il servizio dentro il container crasha, se il processo termina per errore, se il sistema operativo viene riavviato, il comportamento di default di Docker è non fare nulla - il container resta in stato Exited e l'amministratore deve intervenire manualmente. In produzione questo comportamento è inaccettabile per qualunque servizio critico, perché qualsiasi incidente minore (un segnale SIGTERM da un aggiornamento di pacchetto, un out of memory transiente, un bug applicativo che causa un crash del processo principale) si trasforma automaticamente in un incidente grave di indisponibilità prolungata, perché nessuno si accorge del problema finché un utente non segnala che l'applicazione è down.
Sul cliente bresciano, questa era esattamente la causa prima dell'incidente del 13 luglio. Il file compose.yaml aveva zero restart policy configurate su qualsiasi servizio. Quando il kernel si è aggiornato e il sistema si è riavviato, il daemon Docker è ripartito correttamente, ma i container non sono stati ricreati - sono rimasti nello stato Exited in cui erano al momento dello shutdown. Il fix definitivo è la configurazione esplicita della restart policy unless-stopped su ogni service di produzione, che significa "riavvia sempre il container a meno che io ti abbia detto esplicitamente di fermarlo". La restart policy always si comporta in modo simile ma riparte anche dopo un docker stop esplicito, che è quasi sempre un comportamento indesiderato perché maschera le operazioni di manutenzione pianificate. La restart policy on-failure:5 riparte solo se il container esce con codice non-zero e al massimo cinque volte, che è utile per worker stateless ma può portare a container permanentemente fermi dopo cinque crash consecutivi (cosa spesso indesiderata per servizi critici). La scelta standard che applico in tutti i contesti PMI è unless-stopped per ogni servizio, con docker compose stop esplicito usato soltanto durante finestre di manutenzione annunciate.
La seconda cosa che configuro sempre in aggiunta alla restart policy è il healthcheck a livello di container. La restart policy da sola reagisce solo al processo principale che termina: se il processo principale è vivo ma il servizio interno è bloccato (un MySQL che accetta connessioni TCP ma non risponde alle query, un PHP-FPM che ha tutti i worker saturati, un Nginx che carica ma restituisce 502), la restart policy non si accorge del problema. L'healthcheck è la seconda linea di difesa: un comando eseguito periodicamente dentro il container che verifica se il servizio è davvero funzionante, e che marca il container come unhealthy se il check fallisce ripetutamente. La configurazione tipica che uso per un MariaDB di produzione è questa:
services:
db:
image: mariadb:11.4
restart: unless-stopped
healthcheck:
test: ["CMD", "healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
volumes:
- db_data:/var/lib/mysql
environment:
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
secrets:
- db_root_passwordIl parametro start_period: 60s è importante perché dice a Docker di non contare i fallimenti dell'healthcheck nei primi 60 secondi dall'avvio - quel lasso di tempo serve a MariaDB per inizializzare davvero il proprio motore InnoDB, e senza questa grazia iniziale il container verrebbe marcato unhealthy subito dopo l'avvio causando restart loop. La documentazione ufficiale di Docker Compose sugli healthcheck descrive in dettaglio tutti i parametri disponibili.
Anti-pattern 2: volumi persistenti nel posto sbagliato - la strada più rapida per perdere tutti i dati
Il secondo anti-pattern, che ho trovato con frequenza sconfortante anche in infrastrutture costruite da consulenti presumibilmente esperti, è l'errata gestione dei volumi persistenti. Docker offre sostanzialmente tre modi per rendere persistenti dati di un container: named volumes gestiti da Docker (volumes: db_data:/var/lib/mysql), bind mounts su path specifici dell'host (volumes: /opt/stack/db:/var/lib/mysql), e tmpfs mounts in memoria. Ognuno ha il suo caso d'uso, ma confonderli in produzione porta a conseguenze dolorose.
L'errore più comune - che sul cliente bresciano era esattamente quello che aveva reso ancora più grave l'incidente del 13 luglio - è l'uso di bind mount su /tmp o su partizioni non backuppate. Il server del cliente aveva il volume del database MariaDB montato su /tmp/docker-volumes/mariadb-data. Il precedente sviluppatore aveva scritto nel compose.yaml:
services:
db:
volumes:
- /tmp/docker-volumes/mariadb-data:/var/lib/mysqlFortunatamente /tmp su Debian non viene ripulito a ogni riavvio (il servizio systemd-tmpfiles-clean ha una configurazione che pulisce solo file più vecchi di 10 giorni), ma in altre distribuzioni Linux (RHEL con certe configurazioni, alcune immagini Ubuntu server più aggressive) /tmp viene completamente ripulito a ogni boot, e l'intero database si perderebbe al primo riavvio del server. Anche quando /tmp non viene ripulito, sta comunque su una partizione che in molti template di VPS non rientra nelle policy di backup automatico del provider, ed è comunque una scelta operativamente fragile. Nel nostro caso, fortunatamente, il database era sopravvissuto al riavvio, ma il degrado prestazionale di /tmp pieno di file di sessione PHP e di log rotation misti ai file innodb aveva causato nel tempo una frammentazione dei file ibdata1 che è la ragione prima per cui al riavvio il motore InnoDB non era riuscito a ricaricare pulito il tablespace.
Il pattern corretto è sempre uno dei due seguenti: named volume gestito da Docker (la scelta standard per la stragrande maggioranza dei casi), oppure bind mount su una directory dedicata sotto /var/lib/docker-volumes/ o /srv/docker-data/ con permessi corretti, filesystem ext4 o XFS, e inclusa nelle policy di backup. La scelta tra named volume e bind mount la faccio sulla base di un criterio concreto: se ho bisogno di accedere ai file dal host (per backup via rsync, per ispezione manuale, per import/export diretti) uso bind mount; altrimenti uso named volume, che è gestito interamente dal daemon Docker ed è portable fra macchine (con docker volume export/import). Per il database di produzione uso sempre bind mount su path dedicato, perché voglio poter fare mariabackup --backup --target-dir=/backup/latest direttamente da host verso gli stessi file del container. Per Redis uso named volume, perché la cache è facilmente ricostruibile e non richiedo accesso diretto dai file.
Anti-pattern 3: secrets in variabili d'ambiente nel compose.yaml - il disastro di sicurezza che quasi nessuno vede
Il terzo anti-pattern è la gestione errata dei secrets applicativi. Le credenziali del database, le API key di servizi esterni, i token JWT, i certificati privati: tutti questi sono segreti che non dovrebbero mai apparire in chiaro nei file versionati del repository, né nelle variabili d'ambiente passate al container via la sezione environment: del compose.yaml. Tuttavia, in circa il 60% dei subentri Docker Compose che ho fatto, il file compose.yaml era versionato su git con i segreti in chiaro direttamente nella sezione environment:, oppure era presente un file .env versionato (e questo è ancora peggio, perché viene caricato automaticamente dal daemon Docker senza che l'amministratore se ne renda conto). La conseguenza pratica è che qualunque membro del team che ha accesso al repository git - incluso lo sviluppatore esterno, il tirocinante di passaggio, lo stagista di un fornitore - vede in chiaro la password di root del database di produzione.
Il pattern corretto è l'uso del meccanismo Docker Secrets per Compose, che consente di montare i secret come file dentro il container leggibili solo dal processo principale del container stesso. Il file compose.yaml corretto diventa:
secrets:
db_root_password:
file: ./secrets/db_root_password.txt
db_user_password:
file: ./secrets/db_user_password.txt
app_key:
file: ./secrets/app_key.txt
services:
db:
image: mariadb:11.4
environment:
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
MARIADB_PASSWORD_FILE: /run/secrets/db_user_password
secrets:
- db_root_password
- db_user_password
app:
image: registry.example.com/app:1.4.2
environment:
APP_KEY_FILE: /run/secrets/app_key
secrets:
- app_keyLa directory ./secrets/ contiene file di testo con permessi chmod 600 owner root, non è versionata su git (aggiunta a .gitignore), e viene popolata manualmente sul server di produzione durante il setup iniziale con password generate da openssl rand -hex 32. L'applicazione Laravel o Symfony va adattata per leggere i valori dai file (per Symfony esiste nativamente il pattern %env(file:APP_KEY_FILE)%, per Laravel c'è la helper file_get_contents(env('APP_KEY_FILE')) incapsulata in un config loader personalizzato). Il beneficio operativo è che anche se un attaccante ottiene accesso al repository git tramite una configurazione errata o una pipeline CI compromessa, non vede nessun secret in chiaro. Ho descritto in dettaglio il design end-to-end della gestione dei secret per applicazioni PHP in produzione nel mio articolo su crittografia PHP con libsodium per cifrare correttamente i dati sensibili in produzione, che copre l'aspetto applicativo della gestione dei segreti dopo che sono stati caricati correttamente in memoria.
Anti-pattern 4: logging senza rotazione - il disco pieno che ti ammazza il server
Il quarto anti-pattern, meno appariscente dei precedenti ma capace di causare downtime imprevisti a mesi di distanza dall'installazione, è l'assenza di rotazione dei log nei container. Di default, Docker memorizza lo stdout e lo stderr di ogni container in un file JSON su disco (/var/lib/docker/containers/<id>/<id>-json.log), senza limiti di dimensione e senza rotazione. Un container di un'applicazione attiva può produrre facilmente 100-500 MB di log al giorno, e in pochi mesi il solo directory di log di Docker può riempire centinaia di gigabyte di disco. Il giorno in cui il disco del VPS si riempie, tutti i container si fermano, l'applicazione va down, e l'amministratore deve intervenire d'urgenza per pulire lo spazio - spesso perdendo i log stessi nel processo di pulizia manuale.
La configurazione corretta si fa a livello di service nel compose.yaml con il driver json-file e parametri espliciti di rotazione:
services:
app:
image: registry.example.com/app:1.4.2
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
compress: "true"Questa configurazione limita ciascun file di log a 10 MB, mantiene al massimo 5 file per container (quindi 50 MB totali per container), e comprime automaticamente i file ruotati - dieci volte meno spazio su disco rispetto a file non compressi. Per applicazioni che generano molto volume di log e dove la perdita del log ruotato non è accettabile (applicazioni con audit trail normativo, servizi finanziari, API che devono mantenere log per compliance GDPR/NIS2), il pattern corretto è invece il driver syslog o journald o un driver esterno come fluentd che inoltra i log a un servizio di centralizzazione dedicato. Su PMI tipiche di medie dimensioni, json-file con rotazione a 10 MB x 5 file è il giusto compromesso fra operatività e semplicità. Sul cliente bresciano, prima dell'intervento il directory /var/lib/docker/containers/ pesava 47 GB su un disco da 160 GB, ed era uno dei contributori silenziosi alla pressione generale sul filesystem che aveva aggravato il problema del 13 luglio.
Pattern corretto: update, rollback e zero-downtime in Docker Compose su VPS singolo
L'ultimo elemento architetturale che voglio trattare è il workflow di aggiornamento dell'applicazione in produzione. Docker Compose non offre nativamente capacità di rolling update come Kubernetes - lo docker compose up -d di default ferma il container vecchio, rimuove il vecchio, crea il nuovo, e lo fa per un servizio alla volta. Se il servizio applicativo è unico, c'è una finestra di 3-15 secondi in cui l'applicazione è down. Su applicazioni critiche di produzione questa finestra è spesso inaccettabile, anche quando l'utente finale la vive raramente perché l'aggiornamento è fuori orario lavorativo.
Il pattern che applico per ottenere zero-downtime è blue-green deployment manuale con reverse proxy. Uso un Nginx o un Traefik come reverse proxy container-esterno che fa da front-end a due set paralleli di container applicativi (chiamati per convenzione app-blue e app-green), e durante il deploy: avvio il nuovo set "green" a fianco del "blue" esistente, aspetto che il healthcheck del green passi, cambio la configurazione del reverse proxy per puntare al green, spengo il blue. L'intera procedura è automatizzabile con 40-60 righe di bash e la documentazione ufficiale di Traefik sulle strategie di deployment containerizzato su singolo host copre in dettaglio le configurazioni di service discovery dinamico che rendono questo pattern semplice da implementare. Per team che preferiscono un tooling più strutturato senza passare al livello di orchestrazione di Kubernetes, esistono tool intermedi come Kamal di 37signals o Watchtower per aggiornamenti automatici controllati, ma io preferisco in genere mantenere lo script bash in-house perché è completamente trasparente al team operativo - non c'è magia black-box da debugging. In parallelo a questo, tengo sempre attivo un processo di aggiornamento automatico dei container Docker in produzione con zero downtime che ho descritto in dettaglio in un articolo dedicato e che integra il layer di gestione del rollout controllato.
Il risultato finale dell'intervento sul cliente bresciano, al termine delle 11 ore di emergenza più ulteriori 5 giornate di consolidamento distribuite nei tre mesi successivi, è stato il seguente. Downtime del gestionale ricuperato in 11 ore nette dall'inizio dell'intervento. Ricostruzione completa del compose.yaml con tutte le restart policy, tutti gli healthcheck, tutti i volumi spostati su bind mount dedicati in /srv/docker-data/, tutti i secret migrati su Docker Secrets, logging configurato con rotazione controllata, strategia blue-green implementata per le release applicative. Numero di incidenti di produzione nei sei mesi successivi l'intervento: uno solo, risolto automaticamente dalla restart policy senza intervento umano (un segnale SIGKILL sul container MariaDB causato da un OOM transiente dopo un cron sovradimensionato in orario notturno). Costo infrastrutturale mensile invariato - nessun upgrade hardware, nessun servizio gestito esterno. Costo dell'intervento consulenziale: 4.800 euro per l'emergenza iniziale più 3.200 euro per il consolidamento, per un totale di 8.000 euro una tantum. Confronto con la perdita stimata di una ripetizione dell'incidente di luglio (mediamente 30.000-50.000 euro fra giornate di fermo e costi di recovery emergenziali): ROI di circa 4-6 sulla prima annualità, al netto dei benefici operativi qualitativi sul team.
Se gestisci un'infrastruttura Docker Compose in produzione in una PMI italiana - o la stai ereditando da un tecnico precedente che non è più disponibile per passaggio di consegne - l'audit di configurazione contro i cinque anti-pattern che ho descritto in questo articolo è quasi sempre l'intervento a ROI più alto che puoi fare oggi sul tuo stack. Non richiede riscritture architetturali, non richiede migrazione verso Kubernetes, non richiede team DevOps dedicato: richiede mezza giornata di analisi tecnica e due-tre giornate di implementazione dei pattern corretti. Se vuoi confrontarti su una valutazione tecnica del tuo stack Docker Compose attuale con identificazione dei principali rischi di configurazione e roadmap prioritizzata di remediation, contattami per una consulenza iniziale: in una sessione di analisi guidata produciamo insieme un audit ordinato dei pattern e degli anti-pattern presenti nel tuo compose.yaml, con stime realistiche di impatto operativo e priorità di intervento calibrate sul tuo contesto PMI specifico.