Installare Docker Compose su Ubuntu 24.04
Guida pratica per Docker Engine e Compose v2 su Ubuntu 24.04. Impara a gestire i volumi e a evitare che ufw ignori le porte pubblicate tramite Docker.
Cosa stai costruendo
Docker Compose è la base per quasi tutti gli altri servizi di questo sito. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — ogni guida inizia con l'istruzione "scrivi questo file compose", e questa pagina spiega il significato di tale file. Installerai Docker Engine e il plugin Compose v2 tramite il repository apt ufficiale di Docker su Ubuntu 24.04. Successivamente configurerai uno stack con due servizi — Miniflux, un lettore RSS leggero, e PostgreSQL — poiché questa coppia utilizza tutti i pattern impiegati dalle applicazioni più grandi: immagini con tag specifico, un database con healthcheck, un volume nominato, segreti in un file .env e una porta pubblicata solo su localhost.
L'installazione richiede cinque minuti. Il resto della guida riguarda gli aspetti critici: il gruppo docker che ha privilegi di root, le porte pubblicate che bypassano le regole ufw e il flag su docker compose down che elimina il database senza richiesta di conferma.
Prerequisiti: un VPS KVM con Ubuntu 24.04 appena installato, un utente con privilegi sudo e almeno 1 GB di RAM. È possibile procedere anche con Docker già installato — la prima sezione spiega come rimuoverlo.
Installare dal repo di Docker, non da quello di Ubuntu
Evitare due errori comuni prima di eseguire il primo comando. Il pacchetto docker.io di Ubuntu funziona, ma le versioni sono meno recenti rispetto a quelle di Docker e manca la struttura dei plugin necessaria. Il binario standalone docker-compose (con il trattino) è Compose v1: basato su Python e fuori supporto dal 2023; è il motivo per cui i tutorial datati falliscono. Compose oggi è docker compose (con lo spazio), un plugin CLI installato dallo stesso repository del motore.
Se questi componenti sono già presenti sul sistema, rimuoverli prima — incluso docker-compose-v2, il pacchetto del plugin di Ubuntu, per garantire che tutto provenga 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 standard su un VPS nuovo. 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 riportano le stringhe di versione — Docker Compose version v2.x.x conferma la presenza del plugin e non del binario v1 obsoleto. Il comando hello-world deve terminare con Hello from Docker!. Il pacchetto abilita il servizio all'avvio; systemctl is-enabled docker riporta enabled.
Il gruppo docker è root — valuta con consapevolezza
Attualmente ogni comando docker richiede sudo, poiché il socket del daemon in /var/run/docker.sock è di proprietà di root e del gruppo docker. Senza l'appartenenza al gruppo si ottiene l'errore Docker più comune:
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sockLa soluzione standard:
sudo usermod -aG docker $USERL'appartenenza al gruppo viene applicata al login; l'errore persiste nella sessione shell corrente. Esegui newgrp docker per la sessione attuale, oppure effettua il logout e di nuovo il login; id dovrebbe elencare docker tra i tuoi gruppi.
La spiegazione onesta è la seguente: l'appartenenza al gruppo docker equivale a root sull'host. Non è "quasi root" o "privilegiato" — è root. Chiunque faccia parte di quel gruppo può eseguire docker run --rm -it -v /:/host alpine chroot /host e controllare l'intero filesystem, senza richiedere password. Il gruppo esiste per comodità, non per isolamento.
La modalità rootless di Docker è l'alternativa reale — il daemon stesso viene eseguito come utente non privilegiato. Questo comporta dei costi: le porte inferiori a 1024 richiedono una configurazione aggiuntiva, il networking passa attraverso un shim in userspace con un overhead misurabile, e alcune immagini non funzionano correttamente senza root reale. Su un VPS con un singolo amministratore che possiede già i privilegi sudo, l'aggiunta al gruppo non cambia nulla in pratica, ed è ciò che ogni guida qui presente assume — non assegnare questo accesso come se fosse meno potente di sudo.
Anatomy of a compose file
Assegna a ogni stack la propria directory — il nome della directory diventa il nome del progetto, che funge da prefisso per container, network e volumi:
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxCrea compose.yml (il nome moderno; docker-compose.yml funziona ancora). Salta la vecchia chiave version: — è obsoleta e Compose emette 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 sopra è una decisione. Applicale una alla volta.
Pin image versions — :latest plus a pull is an unattended upgrade
postgres:16-alpine, non postgres:latest. Un tag non è statico: :latest punta a ciò che il maintainer ha caricato più recentemente ogni volta che esegui un pull. Combina questo con l'abitudine di aggiornamento di routine che stai per imparare — docker compose pull && docker compose up -d — e :latest significa che i salti di major-version avvengono quando vengono rilasciati upstream, non quando lo decidi tu. Con PostgreSQL non è un'ipotesi: un salto improvviso dalla versione 16 alla 17 lascia il container in crash-loop a causa di una directory dati incompatibile, poiché gli upgrade major di Postgres richiedono un dump e un restore, non un semplice restart.
Blocca almeno la major version (postgres:16-alpine segue i patch release 16.x), e blocca le applicazioni a un release esatto come miniflux/miniflux:2.2.9 — controlla la pagina dei release del progetto e usa la versione corrente quando scrivi il file. Un aggiornamento diventa quindi un'editazione di una riga fatta intenzionalmente, visibile in git diff.
Publish to 127.0.0.1, because Docker walks around ufw
"127.0.0.1:8080:8080" — indirizzo host, porta host, porta container. La maggior parte dei tutorial scrive "8080:8080", che è l'abbreviazione di 0.0.0.0:8080:8080: ascolto su ogni interfaccia, inclusa quella pubblica.
Ecco la trappola, che colpisce quasi tutti una volta. Docker pubblica una porta scrivendo una regola DNAT che riscrive la destinazione del pacchetto sull'IP interno del container prima del filtraggio; il pacchetto segue quindi il percorso FORWARD e non tocca mai INPUT, dove risiedono le tue regole ufw. sudo ufw deny 8080 riporta successo, ufw status mostra la porta negata, ma il servizio risponde comunque a tutta internet. Il tuo firewall non è rotto; viene aggirato per design. Why Docker bypasses ufw, and how to filter container traffic for real spiega il meccanismo e la correzione DOCKER-USER per le porte che devono rimanere pubbliche.
L'abitudine che elimina il problema: vincola le porte pubblicate a 127.0.0.1 a meno che tu non abbia ragioni specifiche per non farlo, e metti un reverse proxy davanti per tutto ciò che deve essere esposto al mondo. Questo è esattamente ciò che la Traefik reverse proxy guide costruisce come passo successivo a questa pagina — un singolo container che gestisce le porte 80 e 443 e instrada tutto il resto tramite hostname, con TLS. (Vieni da una vecchia configurazione Traefik v2? La Traefik v2 to v3 migration guide copre i rinominamenti e i cambiamenti delle regole.)
Verifica il bind dopo aver avviato lo stack: sudo ss -tlnp | grep 8080 dovrebbe mostrare 127.0.0.1:8080, non 0.0.0.0:8080 o *:8080.
Named volumes vs bind mounts
db-data:/var/lib/postgresql/data è un named volume: 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 mappa un percorso scelto sull'host.
La distinzione utile nella pratica: named volumes solo per i container che gestiscono dati — database prima di tutto, poiché Docker inizializza il volume con la proprietà (ownership) prevista dall'immagine e i permessi dei file funzionano correttamente. Bind mounts per i file che gestisci dall'host — file di configurazione che modifichi con un editor di testo, una libreria media che carichi con rsync, qualsiasi cosa il cui percorso debba essere esplicito. Il classico errore dei bind-mount è la proprietà: il container gira come UID 999, la tua directory host è di proprietà di UID 1000, e l'app fallisce all'avvio con permission denied nei log. I named volume fanno quasi scomparire questa categoria di bug, al costo che i dati risiedano in un percorso gestito da Docker — dettagliato sotto.
environment and .env — keep secrets out of git
${POSTGRES_PASSWORD} non viene letto dalla tua shell; Compose lo interpola da 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. Hex, non base64, appositamente: questa password finisce nella stringa di connessione DATABASE_URL, e i caratteri /, + e = prodotti da base64 rompono il parsing dell'URL — un errore che si manifesta come errore di autenticazione, non come errore di sintassi, e fa perdere un'intera serata. La riga .gitignore va inserita prima del primo commit: il file compose è sicuro da pubblicare e versionare, il file .env no, e un segreto che ha toccato la cronologia git è un segreto da ruotare. Se avvii lo stack con una variabile mancante, Compose emette un avviso esplicito e continua con una stringa vuota — che per una password Postgres significa un deployment fallito:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose config stampa il file completamente interpolato — il modo più veloce per controllare cosa riceveranno effettivamente i container; ricorda che l'output include i tuoi segreti.
depends_on waits for nothing — unless you add a healthcheck
Un semplice depends_on: [db] controlla solo l'ordine di avvio: Compose avvia Postgres per primo e l'app un momento dopo, mentre Postgres è ancora a secondi di distanza dall'accettare connessioni. L'app interroga il database, fallisce e crasha o tenta il riavvio a seconda di quanto è scritta bene.
La versione affidabile è quella usata nel file sopra: il servizio db definisce un healthcheck (Postgres include pg_isready esattamente per questo), e l'app dichiara depends_on con condition: service_healthy. Compose avvia il database, interroga il check ogni 10 secondi e avvia Miniflux solo quando il check è superato. Se il database non diventa mai "healthy" — password errata, volume corrotto — l'app non parte mai e Compose ti indica quale dipendenza è fallita:
dependency failed to start: container miniflux-db-1 is unhealthyQuel messaggio ti rimanda a docker compose logs db, dove risiede il vero errore.
restart: unless-stopped
restart: unless-stopped su entrambi i servizi significa che i container tornano attivi dopo un crash e dopo il riavvio di un VPS, ma restano spenti se hai eseguito deliberatamente docker compose stop. L'alternativa always ripristina i container anche dopo un arresto manuale — raramente è ciò che si desidera. Senza una policy di restart, un riavvio per aggiornamento del kernel alle 4 del mattino spegne silenziosamente i tuoi servizi finché non te ne accorgi.
I comandi quotidiani
Tutte le operazioni quotidiane si compiono con cinque comandi, da eseguire 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 è sicuro da eseguire ripetutamente: confronta il file con lo stato attuale e modifica solo i servizi la cui configurazione o immagine è cambiata. La coppia di upgrade scarica ciò a cui puntano i tag fissati: patch sotto la versione postgres:16-alpine, nessuna modifica per i pin esatti finché non vengono modificati — che è lo scopo del sistema. Le vecchie immagini si accumulano dopo gli upgrade; libera lo spazio su disco con docker image prune -f.
Ora il comando distruttivo, avvertenza importante: docker compose down è sicuro — i container e la rete sono eliminabili, i dati risiedono nel volume. docker compose down -v elimina anche i volumi nominati. Questo significa che il database viene rimosso istantaneamente, senza conferma e senza possibilità di annullare l'operazione. Il flag -v serve per smantellare gli esperimenti; su uno stack con dati reali, usalo con la stessa cautela di rm -rf. Non esiste un cestino per /var/lib/docker/volumes/.
Per una shell temporanea all'interno di un container in esecuzione: docker compose exec db psql -U miniflux ti connette al database, e docker compose exec miniflux sh ti fornisce una shell nell'app.
Dove risiedono i dati
I volumi nominati 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 include la riga rilevante:
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"Quella directory è il database: appartiene all'utente root, si trova sul filesystem dell'host e persiste dopo down, aggiornamenti e ricostruzione dei container. È esattamente ciò che i backup devono includere.
Back up a named volume
Il metodo standard prevede l'uso di un container temporaneo che monta il volume in modalità read-only accanto a una directory dell'host, per poi eseguire un comando tar:
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 .Nessuna installazione è necessaria, nessun processo rimane attivo e il ripristino è l'immagine speculare — tar xzf in un nuovo volume vuoto con gli stessi mount invertiti.
Una nota per i database: eseguire il tar di una directory dati di Postgres in esecuzione può catturare uno stato di scrittura incompleto che impedirà l'avvio corretto. È necessario usare docker compose stop per i secondi necessari al comando tar o, preferibilmente, eseguire un dump logico, che è consistente per progettazione:
docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gzIl flag -T disabilita il pseudo-terminale allocato di default da Compose — inviare l'output del dump attraverso un TTY può corromperlo. Inserire uno di questi comandi in cron e copiare il risultato fuori dal VPS; un backup salvato sullo stesso disco dei dati che protegge è una copia, non un backup. La guida Nextcloud implementa una routine pianificata completa basata esattamente su questi due modelli.
Modalità di errore e relativi messaggi
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 l'appartenenza è avvenuta dopo l'apertura della sessione corrente. id mostra i gruppi attivi; newgrp docker corregge la shell corrente; disconnettersi e rientrare risolve tutti i problemi di gruppo.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — problema differente: il daemon è spento. sudo systemctl status docker e sudo journalctl -u docker -n 50 indicano la causa. Su un VPS la causa comune è il disco pieno — controlla prima con df -h /var/lib/docker.
Bind for 127.0.0.1:8080 failed: port is already allocated — un altro container sta già utilizzando la porta dell'host. docker ps mostra quale; spesso il colpevole è un container residuo da un esperimento docker run di settimane fa. Se docker ps è pulito, la porta è occupata da un processo non Docker: sudo ss -tlnp | grep 8080 ne indica il nome.
yaml: line 14: did not find expected key — errore di indentazione sulla riga indicata o su quella immediatamente superiore. I file Compose sono in formato YAML: utilizzare due spazi per l'indentazione, usare solo spazi; la presenza di un carattere tab è fatale. docker compose config valida il file senza avviare alcun servizio; eseguire il comando dopo ogni modifica è una buona pratica.
L'imprevisto di ufw non restituisce alcun errore, il che lo rende pericoloso: il deploy ha successo, ufw status appare corretto, ma una scansione delle porte dall'esterno trova comunque il database. Rileggi la sezione dedicata alle porte, verifica che ogni voce ports: abbia il prefisso 127.0.0.1: e conferma la configurazione da una macchina diversa usando curl http://your-vps-ip:8080 — la risposta corretta deve essere connection refused.
Da questo punto, la guida a Traefik trasforma questo singolo stack in molte applicazioni dietro un unico punto di accesso HTTPS; cosa vale la pena self-hostare nel 2026 è l'elenco delle applicazioni da gestire tramite questo metodo.
Un server di gioco come un server Minecraft su un VPS è un progetto Compose ideale per iniziare a fare pratica.
FAQ
Why do I get "permission denied while trying to connect to the Docker daemon socket"?
Your user is not in the docker group, or was added after the current session began — membership only applies at login. Run sudo usermod -aG docker $USER, then newgrp docker or log out and back in, and confirm with id. The group grants root-equivalent access to the host, so only add users you would give sudo.
Does docker compose down delete my data?
Plain docker compose down does not — it removes containers and the project network; named volumes survive and the next up -d reattaches them. docker compose down -v is the destructive form: it deletes the named volumes, meaning your database, with no confirmation and no undo. Never run -v on a stack with real data unless you hold a verified backup.
What is the difference between docker-compose and docker compose?
docker-compose (hyphen) is Compose v1, a standalone Python binary that reached end of life in 2023 and should not be installed on new servers. docker compose (space) is Compose v2, a Go plugin for the Docker CLI, installed as docker-compose-plugin from Docker's apt repository. Commands and YAML are almost fully compatible, so when an old tutorial says docker-compose up, type docker compose up.
Why can I reach my Docker container from the internet even though ufw blocks the port?
Because Docker publishes ports with DNAT rules in iptables' PREROUTING chain, and the rewritten packets travel the FORWARD path through Docker's own chains — they never hit the INPUT chain where ufw's rules apply. ufw deny 8080 therefore does nothing to a published container port. Fix it at the source: publish to 127.0.0.1: and expose services through a reverse proxy instead.
Should I use a named volume or a bind mount?
Named volumes for data only the container touches — databases especially, since Docker sets the ownership the image expects and permissions just work. Bind mounts for files you also handle from the host: configs you edit, media you upload, anything whose path you want obvious. If a container fails at startup with permission denied on a bind mount, host-vs-container UID mismatch is the first thing to check.