Docker Compose su VPS Ubuntu 24.04: guida base
Installa Docker Engine e Compose v2 su Ubuntu 24.04, crea uno stack Miniflux e PostgreSQL, evita il problema delle porte con ufw e salva i volumi.
Cosa stai creando
Docker Compose è la base di quasi tutto il resto del sito. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat: tutte queste guide iniziano con «scrivi questo file Compose». Questa pagina spiega che cosa contiene realmente quel file. Installerai Docker Engine e il plugin Compose v2 dal repository apt ufficiale di Docker su Ubuntu 24.04. Poi avvierai uno stack reale composto da due servizi: Miniflux, un lettore RSS leggero, e PostgreSQL. Questa coppia consente di applicare tutti i pattern usati dalle applicazioni più grandi: immagini fissate a una versione specifica, 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 un altro modo per ottenere privilegi root; le porte pubblicate, che possono bypassare direttamente le regole di ufw; e l'unico flag di docker compose down che elimina il database senza chiedere conferma.
Prerequisiti: una KVM VPS Ubuntu 24.04 appena installata, un utente con sudo e almeno un gigabyte di RAM. Va bene anche un'installazione Docker esistente: la prima sezione spiega che cosa rimuovere.
Installare dal repository di Docker, non da quello di Ubuntu
Prima del primo comando, evitare due scelte errate. Il pacchetto docker.io fornito da Ubuntu funziona, ma segue con ritardo le release di Docker e non include la disposizione dei plugin prevista dal resto della procedura. Anche il binario autonomo docker-compose, quello con il trattino, appartiene a Compose v1: è basato su Python, non è più supportato dal 2023 e causa il malfunzionamento dei tutorial più vecchi. Oggi Compose è docker compose con uno spazio: un plugin della CLI installato dallo stesso repository dell'engine.
Se uno di questi componenti è già presente sul server, rimuoverlo prima, incluso docker-compose-v2, il pacchetto con cui Ubuntu distribuisce il plugin. In questo modo tutto proverrà da un unico repository:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runcPackage 'docker.io' is not installed, so not removed è l'output normale su un VPS appena installato. Aggiungere quindi il repository di Docker e installare:
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-pluginVerificare tutti e tre i livelli:
docker --version
docker compose version
sudo docker run --rm hello-worldI primi due comandi stampano le stringhe di versione. Docker Compose version v2.x.x conferma che è installato il plugin e non il binario v1 non più supportato. L'esecuzione di hello-world dovrebbe 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 chiave obsoleta version:: Compose mostra un avviso se 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 esprime una scelta. Esaminale una alla volta.
Fissa le versioni delle immagini: :latest con un pull esegue un aggiornamento non presidiato
postgres:16-alpine, non postgres:latest. Un tag non è bloccato: :latest viene risolto nuovamente in base all'ultima versione pubblicata dal manutentore, ogni volta che esegui il pull. Se lo combini con la procedura di aggiornamento ordinaria che stai per imparare, docker compose pull && docker compose up -d, :latest implica che i passaggi a una versione principale arrivano appena il progetto upstream li pubblica, 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 un dump e un restore, non un riavvio.
Fissa almeno la versione principale (postgres:16-alpine segue gli aggiornamenti patch della serie 16.x) e assegna alle applicazioni una release esatta, ad esempio miniflux/miniflux:2.2.9. Controlla la pagina delle release del progetto e usa la versione corrente quando scrivi il file. Un aggiornamento diventa così una modifica di una sola 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 usa "8080:8080", che è l'abbreviazione di 0.0.0.0:8080:8080: ascolto su ogni interfaccia, inclusa quella pubblica.
Questo è il problema, e prima o poi coinvolge quasi tutti. Docker pubblica una porta creando una regola DNAT che riscrive la destinazione del pacchetto nell'indirizzo IP interno del container prima del filtraggio. Il pacchetto segue quindi il percorso FORWARD e non passa mai da INPUT, dove risiedono le regole di ufw. sudo ufw deny 8080 indica che la configurazione è riuscita, ufw status mostra la porta come negata, ma il servizio continua a rispondere a Internet. Il firewall non è guasto: viene aggirato intenzionalmente. Perché Docker aggira ufw e come filtrare davvero il traffico dei container illustra 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 motivi specifici per non farlo, e usa un reverse proxy davanti a tutto ciò che deve essere esposto al mondo esterno. È 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 agli 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 la rinomina dei componenti e le modifiche alle regole.
Verifica l'associazione dopo aver avviato lo stack: sudo ss -tlnp | grep 8080 dovrebbe mostrare 127.0.0.1:8080, non 0.0.0.0:8080 né *: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 più affidabile in pratica è questa: usa volumi denominati solo per i dati gestiti dai container, soprattutto per i database, perché Docker inizializza il volume con la proprietà prevista dall'immagine e i permessi dei file funzionano senza interventi aggiuntivi. Usa bind mount per i file che modifichi dall'host, come file di configurazione che editi con un editor di testo o una libreria multimediale in cui sincronizzi i file con rsync, cioè per tutto ciò il cui percorso vuoi rendere esplicito. Il problema tipico dei bind mount riguarda la proprietà dei file: il container viene eseguito con UID 999, la directory sull'host appartiene a UID 1000 e l'applicazione si arresta all'avvio con permission denied nei log. I volumi denominati eliminano quasi sempre questa classe di errori, al costo di conservare i dati in un percorso gestito da Docker, descritto più avanti.
environment e .env: non inserire i secret in git
${POSTGRES_PASSWORD} non viene letto dalla shell. Compose lo sostituisce usando un file chiamato .env, situato accanto a compose.yml. Crealo:
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 e i caratteri /, + e = prodotti da base64 interrompono l'analisi dell'URL. L'errore si manifesta come un problema di autenticazione, non come un errore di sintassi, e può richiedere ore per essere individuato. La riga .gitignore deve essere aggiunta prima del primo commit: il file Compose può essere pubblicato e versionato, mentre il file .env non deve mai esserlo. Un secret che è entrato nella cronologia di git è un secret da sostituire. Se avvii lo stack senza una variabile, Compose mostra un avviso e continua usando una stringa vuota. Per la password di Postgres questo produce una distribuzione non funzionante:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose config stampa il file dopo l'interpolazione completa. È il modo più rapido per verificare cosa riceveranno effettivamente i container; ricorda che l'output include i secret.
depends_on non attende nulla, a meno che tu non aggiunga un healthcheck
Un depends_on: [db] semplice controlla solo l'ordine di avvio: Compose avvia prima Postgres e l'applicazione poco dopo, mentre Postgres impiega ancora alcuni secondi prima di accettare connessioni. L'applicazione prova a raggiungere il database, fallisce e si arresta o riprova, in base alla qualità della sua implementazione.
La versione affidabile è quella usata dal file precedente: il servizio db definisce un healthcheck (Postgres fornisce 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 diventa mai disponibile, ad esempio a causa di una password errata o di 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 sì che i container vengano riavviati dopo un arresto anomalo e dopo il riavvio di un VPS, ma restino arrestati se hai eseguito intenzionalmente docker compose stop. L'alternativa always riavvia i container anche dopo un arresto manuale, cosa che raramente corrisponde all'intenzione. Senza una policy di riavvio, il riavvio del sistema dovuto a un aggiornamento del kernel alle 4 del mattino arresta 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 effettivamente i dati
I volumi denominati ricevono il prefisso del progetto. Di conseguenza, db-data in una directory denominata miniflux diventa miniflux_db-data:
docker volume ls
docker volume inspect miniflux_db-dataL'output di inspect contiene la riga rilevante:
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"Quella directory contiene il database, appartiene a down sul filesystem dell'host e sopravvive a down, agli aggiornamenti e alla ricostruzione dei container. È anche esattamente ciò che i backup devono includere.
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 e messaggi visualizzati
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, l’utente non appartiene ancora al gruppo docker oppure la sessione è stata avviata prima dell’aggiunta al gruppo. id mostra i gruppi effettivi dell’utente; newgrp docker aggiorna la shell corrente, mentre per aggiornare tutte le sessioni è necessario uscire e accedere di nuovo.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, il problema è diverso: il daemon non è in esecuzione. sudo systemctl status docker e sudo journalctl -u docker -n 50 indicano il motivo. Su un VPS, la causa tipica è il disco pieno: eseguire 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 sull’host. docker ps mostra quale; nella maggior parte dei casi la causa è un container obsoleto lasciato da un esperimento con docker run eseguito alcune settimane prima. Se docker ps non restituisce risultati, la porta è utilizzata da un processo non gestito da Docker: sudo ss -tlnp | grep 8080 ne identifica il processo.
yaml: line 14: did not find expected key, si è verificato un errore di indentazione nella riga indicata o immediatamente sopra di essa. I file Compose sono file YAML: usare un’indentazione di due spazi, solo spazi, e non inserire mai caratteri di tabulazione. La presenza di una tabulazione causa un errore. docker compose config verifica il file senza avviare nulla; eseguirlo dopo ogni modifica è una buona abitudine a basso costo.
La sorpresa di ufw non produce alcun errore, ed è proprio questo a renderla pericolosa: il deploy riesce, ufw status sembra corretto, ma una scansione delle porte dall’esterno rileva comunque il database. Rileggere la sezione sulle porte riportata sopra, controllare ogni voce ports: per verificare che non manchi il prefisso 127.0.0.1: e confermare da un computer diverso usando curl http://your-vps-ip:8080: la risposta che si vuole ottenere è «connection refused».
Da qui, la guida a Traefik trasforma questo singolo stack in più applicazioni pubblicate dietro un unico punto di accesso HTTPS, mentre cosa vale la pena self-hostare nel 2026 offre l’elenco dei servizi da eseguire tramite quel proxy. Quando sono in esecuzione diversi stack e ognuno usa un proprio modulo di accesso, un server SSO self-hosted come Authentik li riunisce in un unico account dietro lo stesso proxy.
Un game server, ad esempio un server Minecraft su un VPS, è un buon primo progetto Compose su cui fare pratica. Se preferisci imparare usando qualcosa che apri ogni giorno, openGym, un tracker di allenamento self-hosted, è uno stack ridotto fissato a un tag git anziché a un tag dell'immagine e richiede TLS davanti al servizio prima di registrare la prima passkey. Le foto sono spesso i primi dati che si desidera recuperare dal cloud di terzi e il confronto tra PhotoPrism e Immich chiarisce il quantitativo minimo di RAM e la procedura di backup da adottare prima di assegnare un volume a una delle due soluzioni. Quando due servizi non sono più sufficienti, installare AFFiNE come workspace in stile Notion applica gli stessi schemi a quattro container e verifica concretamente se i tag fissati, gli healthcheck e i volumi denominati indicati sopra sono ormai diventati una pratica abituale.
FAQ
Perché ricevo l'errore "permission denied while trying to connect to the Docker daemon socket"?
L'utente non appartiene al gruppo docker oppure è stato aggiunto dopo l'avvio della sessione corrente. L'appartenenza al gruppo diventa effettiva solo al login. Esegui sudo usermod -aG docker $USER, quindi newgrp docker, oppure esegui il logout e accedi di nuovo. Verifica con id. Il gruppo concede un accesso equivalente a root all'host, quindi aggiungi soltanto utenti ai quali concederesti sudo.
docker compose down elimina i miei dati?
Il comando docker compose down, senza opzioni aggiuntive, non elimina i dati. Rimuove i container e la rete del progetto. I volumi denominati restano disponibili e il comando up -d successivo li ricollega. docker compose down -v è la variante 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 se non disponi di un backup verificato.
Qual è la differenza tra docker-compose e docker compose?
docker-compose (con trattino) è Compose v1, un binario Python autonomo che ha raggiunto la fine del ciclo di vita nel 2023 e non deve essere installato sui nuovi server. docker compose (con spazio) è Compose v2, un plugin Go per la Docker CLI, installato come docker-compose-plugin dal repository apt di Docker. I comandi e i file YAML sono quasi completamente compatibili. Quando un tutorial obsoleto indica docker-compose up, digita docker compose up.
Perché posso raggiungere il mio container Docker da Internet anche se ufw blocca la porta?
Docker pubblica le porte usando regole DNAT nella catena PREROUTING di iptables. I pacchetti riscritti seguono il percorso FORWARD attraverso le catene gestite da Docker e non raggiungono mai la catena INPUT, nella quale si applicano le regole di ufw. Di conseguenza, ufw deny 8080 non ha effetto sulla porta pubblicata di un container. Risolvi 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à prevista dall'immagine e i permessi funzionano senza ulteriori interventi. 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 restituisce permission denied su un bind mount, il primo controllo deve riguardare una mancata corrispondenza tra gli UID dell'host e quelli del container.