Docker Compose su VPS: guida base per Ubuntu 24.04
Installa Docker Engine e il plugin Compose su Ubuntu 24.04, crea uno stack Miniflux e PostgreSQL, evita il problema delle porte con ufw e fai il backup dei volumi.
Cosa stai creando
Docker Compose è la base di quasi tutto il resto di questo sito. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat: tutte queste guide iniziano con «scrivi questo file Compose» e questa pagina spiega che cosa significa realmente quel file. Installerai Docker Engine e il plugin Compose dal repository apt ufficiale di Docker su Ubuntu 24.04, quindi avvierai uno stack reale composto da due servizi: Miniflux, un piccolo lettore RSS, e PostgreSQL. Questa coppia permette di applicare tutti i modelli usati dalle applicazioni più grandi: immagini con versione fissata, un database con healthcheck, un volume denominato, secret in un file .env e una porta pubblicata soltanto su localhost.
L'installazione richiede cinque minuti. Il resto della guida tratta gli aspetti che possono creare problemi in seguito: il gruppo docker, che equivale di fatto a root, le porte pubblicate, che bypassano direttamente le regole di ufw, e l'unico flag di docker compose down che elimina il database senza chiedere conferma.
Prerequisiti: un VPS KVM Ubuntu 24.04 appena installato, un utente con sudo e almeno un gigabyte di RAM. Anche un'installazione Docker esistente va bene: la prima sezione spiega che cosa rimuovere.
Installare dal repository Docker, non da quello di Ubuntu
Prima del primo comando, evita due scelte errate. Il pacchetto docker.io di Ubuntu funziona, ma rimane indietro rispetto alle release di Docker e non include la struttura dei plugin su cui si basano gli altri componenti. Inoltre, il comando con il trattino docker-compose è quello indicato dai tutorial più datati. Su un server meno recente, di solito installa Compose v1, scritto in Python e dichiarato da Docker fuori supporto. È il motivo per cui quei tutorial non funzionano più. Docker pubblica ancora il binario standalone docker-compose, compilato dal codice Go attuale, ma la pagina di installazione ufficiale definisce questo metodo non consigliato e supportato soltanto per la compatibilità con le versioni precedenti. Oggi Compose è docker compose con uno spazio: un plugin della CLI installato dallo stesso repository del motore.
Se uno di questi componenti è già presente sul server, rimuovilo prima. Rimuovi anche docker-compose-v2 e docker-buildx, che sono i pacchetti Ubuntu dei plugin, così tutti i componenti provengono da un unico repository:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runcPackage 'docker.io' is not installed, so not removed è l'output normale su un VPS appena installato. Poi aggiungi il repository Docker e installa i pacchetti:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginVerifica tutti e tre i livelli:
docker --version
docker compose version
sudo docker run --rm hello-worldI primi due comandi stampano le stringhe della versione. Docker Compose version v5.x.x conferma che hai il plugin e non il binario v1 non più supportato. A dicembre 2025 il plugin è passato direttamente da v2 a v5, saltando le versioni 3 e 4 per evitare confusione con le vecchie versioni del formato dei file Compose. Pertanto, le guide che mostrano v2.x.x indicano lo stesso strumento. L'esecuzione di hello-world deve terminare con Hello from Docker!. Il pacchetto abilita il servizio all'avvio; systemctl is-enabled docker stampa enabled.
Il gruppo docker equivale a root: decidete con piena consapevolezza
Al momento ogni comando docker richiede sudo, perché il socket del daemon in /var/run/docker.sock appartiene a root e al gruppo docker. Senza esserne membri, viene visualizzato l'errore Docker più cercato:
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sockLa correzione standard è:
sudo usermod -aG docker $USERL'appartenenza al gruppo diventa effettiva al login, quindi l'errore persiste nella shell corrente. Eseguite newgrp docker per applicarla a questa sessione oppure uscite e accedete di nuovo; a quel punto id dovrebbe elencare docker tra i vostri gruppi.
Ora il punto importante, espresso chiaramente: l'appartenenza al gruppo docker equivale a root sull'host. Non è un accesso "simile a root" né un accesso "elevato": è root. Chiunque appartenga a quel gruppo può eseguire docker run --rm -it -v /:/host alpine chroot /host e ottenere il controllo dell'intero filesystem, senza che venga richiesta alcuna password. Il gruppo esiste per comodità, non per garantire l'isolamento.
La modalità rootless di Docker è la vera alternativa: il daemon stesso viene eseguito dall'utente non privilegiato. Comporta però alcuni costi: per usare porte inferiori a 1024 è necessaria una configurazione aggiuntiva, il networking passa attraverso uno shim in userspace con un overhead misurabile e alcune immagini funzionano in modo anomalo senza i privilegi effettivi di root. Su un VPS amministrato da una sola persona, in cui l'unico accesso dispone già di sudo, il gruppo non cambia nulla nella pratica ed è l'impostazione presupposta da tutta questa guida. Non concedete però l'appartenenza al gruppo come se offrisse privilegi inferiori a sudo.
Anatomia di un file Compose
Assegna a ogni stack una directory dedicata. Il nome della directory diventa il nome del progetto e viene anteposto a container, reti e volumi:
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxCrea compose.yml (il nome moderno; docker-compose.yml continua a funzionare). Non usare la vecchia chiave version:: è obsoleta e Compose mostra un avviso quando la rileva.
services:
miniflux:
image: miniflux/miniflux:2.2.9
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
- DATABASE_URL=postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=miniflux
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:Ogni riga precedente corrisponde a una scelta. Analizzale una alla volta.
Fissa le versioni delle immagini: :latest più un pull equivale a un aggiornamento non supervisionato
postgres:16-alpine, non postgres:latest. Un tag non è immutabile: :latest viene risolto nuovamente alla versione che il maintainer ha pubblicato più di recente ogni volta che esegui un pull. Se lo combini con la consuetudine di aggiornamento che stai per imparare, docker compose pull && docker compose up -d, :latest implica che i passaggi a versioni principali arrivano quando li pubblica il progetto upstream, non quando lo decidi tu. Con PostgreSQL non è un caso teorico: un passaggio imprevisto dalla versione 16 alla 17 lascia il container in un ciclo continuo di arresto e riavvio su una directory dati incompatibile, perché gli aggiornamenti tra versioni principali di Postgres richiedono dump e restore, non un riavvio.
Fissa almeno la versione principale (postgres:16-alpine segue le versioni correttive della serie 16.x) e assegna alle applicazioni una release esatta come miniflux/miniflux:2.2.9. Controlla la pagina delle release del progetto e usa la versione corrente quando scrivi il file. In questo modo un aggiornamento diventa una modifica a una riga, eseguita intenzionalmente e visibile in git diff.
Pubblica su 127.0.0.1, perché Docker aggira ufw
"127.0.0.1:8080:8080": indirizzo dell'host, porta dell'host, porta del container. La maggior parte dei tutorial scrive "8080:8080", che è un'abbreviazione di 0.0.0.0:8080:8080: ascolto su ogni interfaccia, inclusa quella pubblica.
Questa è la trappola e prima o poi colpisce quasi tutti. Docker pubblica una porta scrivendo una regola DNAT che riscrive la destinazione del pacchetto sull'indirizzo IP interno del container prima del filtraggio. Il pacchetto segue quindi il percorso FORWARD e non passa mai da INPUT, dove si trovano le regole di ufw. sudo ufw deny 8080 segnala un'operazione riuscita, ufw status mostra che la porta è negata, ma il servizio continua a rispondere a tutta Internet. Il firewall non è guasto: viene aggirato intenzionalmente. Perché Docker aggira ufw e come filtrare davvero il traffico dei container descrive il meccanismo e la correzione DOCKER-USER per le porte che devono restare pubbliche.
L'abitudine che elimina il problema: associa le porte pubblicate a 127.0.0.1, salvo un motivo specifico per fare diversamente, e metti un reverse proxy davanti a tutto ciò che deve essere esposto al mondo. È esattamente ciò che realizza la guida al reverse proxy Traefik come passaggio successivo a questa pagina: un container che gestisce le porte 80 e 443 e inoltra le richieste a tutti gli altri servizi in base al nome host, con TLS. Se provieni da una configurazione Traefik v2 precedente, la guida alla migrazione da Traefik v2 a v3 descrive le rinominazioni e le modifiche alle regole.
Dopo avere avviato lo stack, verifica l'associazione: sudo ss -tlnp | grep 8080 dovrebbe mostrare 127.0.0.1:8080, non 0.0.0.0:8080 o *:8080.
Volumi denominati e bind mount
db-data:/var/lib/postgresql/data è un volume denominato: Docker crea e gestisce una directory sotto /var/lib/docker/volumes/ e la monta nel container. L'alternativa è un bind mount, ./data:/var/lib/postgresql/data, che associa un percorso scelto sull'host.
La suddivisione che funziona nella pratica è questa: volumi denominati per i dati gestiti esclusivamente dai container, soprattutto per i database, perché Docker inizializza il volume con la proprietà prevista dall'immagine e i permessi funzionano correttamente. Bind mount per i file che modifichi dall'host, come file di configurazione che editi con un editor di testo, una libreria multimediale in cui copi i file con rsync o qualunque contenuto il cui percorso vuoi mantenere evidente. Il problema classico dei bind mount riguarda la proprietà dei file: l'immagine esegue il processo con un UID fisso (l'immagine Miniflux usa UID 65534, postgres:16-alpine passa a UID 70), mentre la directory dell'host appartiene a UID 1000. L'applicazione si arresta all'avvio e nei log compare permission denied. I volumi denominati fanno quasi scomparire questa categoria di errori, al prezzo di conservare i dati in un percorso gestito da Docker, descritto più avanti.
environment e .env: non inserire i secret in git
Compose valorizza ${POSTGRES_PASSWORD} usando un file chiamato .env che si trova accanto a compose.yml, ma solo se la shell non definisce già quella variabile: un valore esportato nell'ambiente della shell ha precedenza sul file .env. Un export POSTGRES_PASSWORD=... dimenticato da un esperimento precedente sovrascrive quindi il file senza segnalazioni evidenti, e docker compose config (più avanti) mostra quale valore è stato utilizzato. Crea il file:
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignoreGenera valori reali con openssl rand -hex 24. Usa esadecimale, non base64, per un motivo preciso: questa password viene inserita nella stringa di connessione DATABASE_URL, mentre i caratteri /, + e = prodotti da base64 interrompono l'analisi dell'URL. L'errore si manifesta come un problema di autenticazione, non di sintassi, e può richiedere ore di troubleshooting. La riga .gitignore va aggiunta prima del primo commit: il file Compose può essere pubblicato e versionato, il file .env non deve mai esserlo, e un secret entrato nella cronologia di git è un secret da ruotare. Se avvii lo stack senza una variabile, Compose mostra un avviso evidente e continua usando una stringa vuota. Per la password di Postgres questo produce un deployment non funzionante:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose config stampa il file con tutte le interpolazioni già applicate. È il modo più rapido per verificare cosa riceveranno effettivamente i container; ricorda che l'output include i tuoi secret.
depends_on non attende nulla, a meno che tu non aggiunga un healthcheck
Un depends_on: [db] semplice controlla soltanto l'ordine di avvio: Compose avvia prima Postgres e poco dopo l'applicazione, mentre Postgres impiega ancora alcuni secondi prima di accettare connessioni. L'applicazione prova a raggiungere il database, fallisce e si arresta oppure riprova, a seconda di come è stata implementata.
La configurazione affidabile è quella usata dal file precedente: il servizio db definisce un healthcheck (Postgres include pg_isready proprio per questo) e l'applicazione dichiara depends_on con condition: service_healthy. Compose avvia il database, esegue il controllo ogni 10 secondi e avvia Miniflux solo quando il controllo ha esito positivo. Se il database non raggiunge mai lo stato healthy, ad esempio per una password errata o un volume danneggiato, l'applicazione non viene avviata e Compose indica quale dipendenza ha avuto esito negativo:
dependency failed to start: container miniflux-db-1 is unhealthyIl messaggio indirizza a docker compose logs db, dove si trova l'errore effettivo.
restart: unless-stopped
restart: unless-stopped su entrambi i servizi fa riavviare i container dopo un arresto anomalo e dopo il riavvio di un VPS, ma li lascia arrestati se hai eseguito deliberatamente docker compose stop. L'alternativa always riavvia i container anche dopo un arresto manuale, cosa che raramente corrisponde all'intenzione. Senza una politica di riavvio, un riavvio causato da un aggiornamento del kernel alle 4:00 interrompe silenziosamente i servizi finché non te ne accorgi.
I comandi di ogni giorno
Per le attività quotidiane bastano cinque comandi, eseguiti dalla directory del progetto.
docker compose up -d # create and start; idempotent, recreates only what changed
docker compose ps # status, ports, and health of this project's containers
docker compose logs -f miniflux # follow one service's logs; --tail 100 for recent history
docker compose pull && docker compose up -d # upgrade to the pinned tags
docker compose down # stop and remove containers and the networkup -d può essere eseguito più volte senza rischi: confronta il file con lo stato effettivo e interviene solo sui servizi la cui configurazione o immagine è cambiata. La coppia di comandi per l'aggiornamento scarica ciò a cui puntano attualmente i tag fissati: le release di correzione sotto postgres:16-alpine, mentre non scarica nulla per un pin esatto finché non lo modifichi. Questo è il comportamento previsto. Dopo gli aggiornamenti, le immagini precedenti si accumulano. Recupera spazio su disco con docker image prune -f.
Ora il comando distruttivo, da usare con la massima attenzione: docker compose down è sicuro perché i container e la rete sono ricreabili e i dati risiedono nel volume. docker compose down -v elimina anche i volumi denominati. Questo elimina immediatamente il database, senza richiesta di conferma e senza possibilità di annullare l'operazione. Il flag -v serve per rimuovere gli ambienti di test. Su uno stack che contiene dati reali, trattalo come rm -rf. Con /var/lib/docker/volumes/ non esiste un cestino.
Per aprire una shell temporanea all'interno di un container in esecuzione: docker compose exec db psql -U miniflux apre una sessione nel database, mentre docker compose exec miniflux sh apre una shell nell'applicazione.
Dove risiedono realmente i dati
I volumi denominati ricevono il prefisso del progetto, quindi db-data in una directory denominata miniflux diventa miniflux_db-data:
docker volume ls
docker volume inspect miniflux_db-dataL'output di inspect include la riga importante:
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"Quella directory contiene il database, appartiene a root sul filesystem dell'host e sopravvive a down, agli aggiornamenti e alla ricostruzione dei container. È anche esattamente ciò che i backup devono acquisire.
Eseguire il backup di un volume denominato
Il modello standard consiste nell'usare un container temporaneo che monta il volume in sola lettura accanto a una directory dell'host, quindi crea un archivio tar da entrambi:
docker run --rm \
-v miniflux_db-data:/data:ro \
-v "$PWD":/backup \
alpine:3.22 tar czf /backup/miniflux-db-$(date +%F).tar.gz -C /data .Non è necessario installare nulla e non resta alcun processo in esecuzione. Per il ripristino si esegue l'operazione inversa, tar xzf in un volume nuovo e vuoto, invertendo gli stessi mount.
Per i database esiste una limitazione importante: creare un archivio tar della directory dati di Postgres mentre il database è in esecuzione può acquisire uno stato intermedio durante una scrittura, impedendo un avvio corretto. È possibile eseguire docker compose stop per i secondi necessari a tar, oppure, soluzione preferibile, creare un dump logico, che per sua natura è coerente:
docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz-T disabilita il pseudo-terminale che Compose assegna per impostazione predefinita. Se l'output del dump passa attraverso un TTY, può corrompersi. Inserire uno di questi comandi in cron e copiare il risultato fuori dal VPS. Un backup sullo stesso disco dei dati che protegge è una copia, non un backup. La guida a Nextcloud costruisce una procedura pianificata completa basata esattamente su questi due modelli.
Modalità di errore, con le stringhe visualizzate
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, non siete ancora nel gruppo docker oppure lo siete, ma la sessione è precedente all'aggiunta. id mostra i gruppi effettivi; newgrp docker corregge la shell corrente, mentre disconnettersi e accedere nuovamente corregge tutte le appartenenze ai gruppi.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, il problema è diverso: il daemon è inattivo. sudo systemctl status docker e sudo journalctl -u docker -n 50 spiegano il motivo. Su un VPS, la causa tipica è il disco pieno: eseguite prima df -h /var/lib/docker.
Bind for 127.0.0.1:8080 failed: port is already allocated, un altro container ha già pubblicato quella porta dell'host. docker ps mostra quale; di solito è un container obsoleto rimasto da un docker run sperimentale di alcune settimane prima. Se docker ps non mostra nulla, la porta è utilizzata da un processo non Docker: sudo ss -tlnp | grep 8080 ne indica il nome.
yaml: line 14: did not find expected key, un errore di indentazione nella riga indicata o subito sopra di essa. I file Compose sono YAML: indentazione di due spazi, solo spazi e nessun carattere di tabulazione, che ovunque si trovi causa un errore fatale. docker compose config valida il file senza avviare nulla; eseguirlo dopo ogni modifica è una buona abitudine a basso costo.
La sorpresa di ufw non stampa alcun errore, ed è questo che la rende pericolosa: il deploy riesce, ufw status sembra corretto e una scansione delle porte dall'esterno trova comunque il database. Rileggete la sezione sulle porte qui sopra, controllate ogni voce ports: per verificare che non manchi il prefisso 127.0.0.1: e confermate da una macchina diversa con curl http://your-vps-ip:8080: la risposta che volete è «connection refused».
Da qui, la guida a Traefik trasforma questo singolo stack in molte applicazioni dietro un unico punto di ingresso HTTPS, mentre cosa vale la pena ospitare autonomamente nel 2026 è l'elenco dei servizi da eseguire tramite il proxy. Quando diversi di questi stack sono in esecuzione e ciascuno ha introdotto il proprio modulo di accesso, un server SSO self-hosted come Authentik li riconduce a un unico account dietro lo stesso proxy.
Un server di gioco, ad esempio un server Minecraft su un VPS, è un buon primo progetto Compose su cui fare pratica. Se preferite imparare con qualcosa che aprite ogni giorno, openGym, un tracker di allenamento self-hosted è uno stack compatto fissato a un tag git anziché a un tag di immagine e richiede TLS davanti al servizio prima di registrare la prima passkey. Le foto sono spesso la prima cosa che si vuole recuperare dal cloud di terzi e confronto tra PhotoPrism e Immich chiarisce la quantità minima di RAM e la procedura di backup che adotterete prima di assegnare un volume all'uno o all'altro. Quando due servizi non bastano più, configurare AFFiNE come workspace in stile Notion applica gli stessi schemi a quattro container e offre un test concreto per verificare se i tag fissati, gli healthcheck e i volumi denominati descritti sopra sono ormai diventati un'abitudine.
FAQ
Perché ricevo "permission denied while trying to connect to the Docker daemon socket"?
Il tuo utente non appartiene al gruppo docker oppure è stato aggiunto dopo l'inizio della sessione corrente. L'appartenenza al gruppo diventa effettiva solo al login. Esegui sudo usermod -aG docker $USER, quindi newgrp docker, oppure disconnettiti e accedi di nuovo. Verifica con id. Il gruppo concede accesso equivalente a quello di root sull'host, quindi aggiungi soltanto utenti a cui concederesti sudo.
docker compose down elimina i miei dati?
Il comando docker compose down senza altre opzioni non elimina i dati. Rimuove i container e la rete del progetto; i volumi denominati rimangono disponibili e la successiva esecuzione di up -d li ricollega. docker compose down -v è invece la forma distruttiva: elimina i volumi denominati, quindi anche il database, senza chiedere conferma e senza possibilità di annullamento. Non eseguire mai -v su uno stack con dati reali, a meno di avere un backup verificato.
Qual è la differenza tra docker-compose e docker compose?
docker-compose, con il trattino, è il vecchio modo di invocare Compose. Sui server meno recenti corrisponde di solito a Compose v1, l'implementazione originale in Python che Docker ha dichiarato fuori supporto. Docker pubblica ancora il binario standalone docker-compose, compilato dal codice Go attuale, ma indica questa installazione come non consigliata e supportata soltanto per la compatibilità con le versioni precedenti. Sui nuovi server, quindi, non usarla. docker compose, con lo spazio, è l'implementazione corrente di Compose: dalla v2 in poi e numerata v5 da dicembre 2025, è un plugin Go per la CLI Docker, installato come docker-compose-plugin dal repository apt di Docker. I comandi e i file YAML sono quasi completamente compatibili. Quando un vecchio tutorial indica docker-compose up, usa docker compose up.
Perché posso raggiungere il mio container Docker da Internet anche se ufw blocca la porta?
Docker pubblica le porte tramite regole DNAT nella catena PREROUTING di iptables. I pacchetti riscritti seguono il percorso FORWARD attraverso le catene proprie di Docker e non raggiungono mai la catena INPUT, dove si applicano le regole di ufw. Di conseguenza, ufw deny 8080 non ha effetto sulla porta pubblicata di un container. Correggi il problema alla fonte: pubblica su 127.0.0.1: ed esponi i servizi tramite un reverse proxy.
Devo usare un volume denominato o un bind mount?
Usa i volumi denominati per i dati gestiti esclusivamente dal container, soprattutto per i database. Docker imposta la proprietà secondo le aspettative dell'immagine e i permessi funzionano normalmente. Usa i bind mount per i file che gestisci anche dall'host: configurazioni che modifichi, contenuti multimediali che carichi e qualsiasi file il cui percorso vuoi rendere esplicito. Se un container non si avvia e mostra permission denied su un bind mount, controlla prima la mancata corrispondenza tra gli UID dell'host e quelli del container.