Come distribuire applicazioni con Docker Compose su un VPS
Guida pratica di produzione a Docker Compose v2 su Ubuntu: installare il motore, scrivere un file Compose con reti e volumi, gestire i segreti, mettere un reverse proxy davanti, aggiornare senza panico e fare il backup dei volumi con nome.

Docker Compose è il modo usuale di far girare uno stack piccolo su un VPS: un’app web, un database, una cache Redis e un reverse proxy, descritti in un file YAML. Non è Kubernetes. Non avrai failover multi-nodo. Avrai un impianto ripetibile e revisionabile, ricreabile in minuti su un nuovo VPS Hiddence: esattamente ciò di cui hanno bisogno la maggior parte dei side project e dei prodotti piccoli.
Questo articolo presuppone che tu voglia qualcosa più vicino alla produzione di un docker run il venerdì sera. Installiamo Docker Engine e il plugin Compose, creiamo un utente di deploy non root, scriviamo un file Compose con healthcheck e policy di riavvio, teniamo i segreti fuori da Git, pubblichiamo su internet solo le porte del reverse proxy e definiamo una routine di aggiornamento e backup. Lo stack di esempio è una tipica app web più PostgreSQL più Caddy, ma lo stesso schema vale per Node, PHP, Python o un gruppo di worker.
Perché Compose su un singolo VPS ha ancora senso
Si salta a Kubernetes perché è di moda, poi si passa il weekend su YAML per un sito. Compose resta leggibile. Versionate il file, documentate le variabili d’ambiente e potete fare diff di una modifica prima di applicarla. L’isolamento basta: un container dell’app compromesso non dovrebbe tenere il socket UNIX del database se avete usato reti e utenti con il minimo privilegio. I limiti di risorse impediscono a un memory leak di congelare l’intero VPS.
- Un file descrive l’intero stack
- I volumi con nome sopravvivono alla ricreazione dei container
- Le reti Docker interne tengono Postgres fuori da internet pubblica
- restart: unless-stopped copre la maggior parte dei riavvii
- Facile da copiare su un secondo VPS quando il primo diventa stretto
Requisiti
Usa un VPS con RAM sufficiente per l’app più Postgres. Un piano da 2 GB è un minimo realistico per app + database + proxy. Si assume Ubuntu 24.04. Non installare Docker da uno Snap a caso senza leggere cosa fa ai cgroup; i passi sotto usano il repository apt ufficiale di Docker.
- Ubuntu 22.04/24.04, root o sudo
- 2 GB di RAM consigliati (1 GB solo per stack minuscoli senza Postgres)
- Un dominio che punta al VPS se vuoi HTTPS automatico
- Git o un altro modo di copiare il progetto sul server
Passo 1: Installare Docker Engine e Compose v2
Compose v2 è un plugin invocato come docker compose (con uno spazio), non il vecchio binario Python docker-compose. Installa entrambi dal repository di Docker per avere le patch di sicurezza aggiornate.
ssh root@YOUR_VPS_IP
apt update && apt -y install ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
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" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker version
docker compose versionPasso 2: Utente di deploy e struttura delle directory
Eseguire Compose come root funziona, ma un utente dedicato nel gruppo docker è più pulito. Metti il progetto in /opt o /srv, non in /root, così backup e permessi sono evidenti. Non rendere mai scrivibile da tutti la directory che contiene .env.
adduser --disabled-password --gecos '' deploy
usermod -aG docker deploy
mkdir -p /srv/app
chown deploy:deploy /srv/app
chmod 750 /srv/app
# Log in as deploy for the rest of the file editing:
# su - deploy
# cd /srv/appPasso 3: Scrivere un file Compose pensato per la produzione
Il file sotto è un modello. Sostituisci l’immagine dell’app con la tua. Postgres non è pubblicato su 0.0.0.0:5432: solo Caddy è pubblicato su 80 e 443. Gli healthcheck impediscono a un reverse proxy di mandare traffico a un’app che sta ancora migrando il database. Fissa i tag delle immagini; latest è come arrivano i guasti a sorpresa.
# /srv/app/compose.yaml
services:
caddy:
image: caddy:2.8-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
app:
condition: service_healthy
app:
image: ghcr.io/example/webapp:1.4.2
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 10s
timeout: 3s
retries: 10
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- backend
networks:
frontend: {}
backend: {}
volumes:
pgdata:
caddy_data:
caddy_config:Passo 4: Segreti, .env e Caddyfile
Metti le password in .env sul server, chmod 600, e non fare mai commit di quel file. Genera le password con openssl rand -base64 32. Il Caddyfile deve solo fare reverse proxy verso il nome del servizio dell’app sulla rete Docker.
# /srv/app/.env (permissions 600)
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me-long-random
POSTGRES_DB=app
# plus any APP_SECRET / NEXTAUTH_SECRET your image needs
# /srv/app/Caddyfile
app.example.com {
encode gzip
reverse_proxy app:8000
}
chmod 600 /srv/app/.env
cd /srv/app
docker compose up -d
docker compose ps
docker compose logs -f --tail=100Passo 5: Firewall, log e limiti di risorse
UFW dovrebbe consentire solo 22, 80 e 443. Docker a volte aggira UFW per le porte pubblicate; se ti serve un firewall host rigoroso, verifica l’interazione attuale Docker + UFW per la tua versione di Ubuntu, oppure pubblica le porte solo su 127.0.0.1 e metti un Caddy host davanti. Imposta limiti di memoria in Compose così Postgres non divora il VPS.
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Optional in compose.yaml under app or db:
# deploy:
# resources:
# limits:
# memory: 512M
# Logs (do not let json-file grow forever):
# { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
# in /etc/docker/daemon.json then: systemctl restart dockerPasso 6: Aggiornamenti e rollback
L’aggiornamento noioso che funziona: scarica le nuove immagini, up -d, osserva gli healthcheck, tieni il tag precedente in git per poterlo rifissare. Non eseguire docker compose down su un database di produzione a meno che tu non intenda smettere di accettare traffico. down -v elimina i volumi: è una cancellazione, non un riavvio.
cd /srv/app
git pull # if the Compose file lives in git
# Change the image tag in compose.yaml, then:
docker compose pull
docker compose up -d
docker compose ps
# Rollback: set the old tag, pull, up -d again
# NEVER: docker compose down -v # this deletes named volumesBackup dei volumi con nome
Uno snapshot del VPS è buono. Un dump logico di Postgres è meglio se devi ripristinare su un’altra macchina. Programmalo con cron. Prova un ripristino una volta, altrimenti non hai backup: hai file che speri siano backup.
# Postgres dump while the db container is running:
docker compose exec -T db pg_dump -U app app | gzip > /var/backups/app-$(date +%F).sql.gz
# Copy off-box
# scp /var/backups/app-*.sql.gz backup-host:~
# Restore sketch (maintenance window):
# gunzip -c app-2026-08-19.sql.gz | docker compose exec -T db psql -U app appRisoluzione dei problemi
Leggi docker compose ps e docker compose logs service. I fallimenti nel pull delle immagini sono di solito limiti di frequenza ghcr/Docker Hub o un’immagine privata senza login. Un 502 da Caddy significa che l’app non è healthy o che l’hostname del proxy è sbagliato (usa il nome del servizio Compose, non localhost, dal container Caddy). Gli errori di permesso sui volumi spesso significano che l’immagine gira come uid 1000 mentre la directory host è di root.
- compose: command not found — hai installato il vecchio nome del binario; usa docker compose
- port is already allocated — qualcos’altro possiede 80/443 (Apache, Nginx, un altro Caddy)
- database connection refused — l’app è partita prima che Postgres fosse pronto; usa healthcheck + condizione depends_on
- disk full — docker system df, poi prune con cautela delle immagini inutilizzate
- permission denied on docker.sock — utente non nel gruppo docker, o serve una nuova sessione di login
Sicurezza
Non pubblicare porte di database. Non usare privileged: true. Non montare /var/run/docker.sock in un container dell’app a meno che tu non stia scrivendo apposta un gestore di container. Tieni il motore aggiornato. Analizza le tue immagini se le costruisci. .env su disco resta un file segreto: limita chi può accedere via SSH alla macchina.
- Niente 5432 / 3306 / 6379 pubblici
- Fissa i digest delle immagini o almeno tag immutabili
- chmod 600 .env
- Unattended-upgrades per il sistema host contano ancora
- Un progetto Compose per app riduce il raggio di esplosione
Consigli
- Metti compose.yaml in git; tieni .env.example senza segreti veri
- Usa i profili per i worker opzionali (docker compose --profile workers up -d)
- Osserva docker stats quando dimensioni il VPS
- I bind mount del codice sorgente non sono lo schema di produzione: cuoci un’immagine
- Se lo stack cresce su più VPS, allora guarda l’orchestrazione, non prima
Un setup Compose quasi di produzione su un VPS è: pacchetti Docker ufficiali, un .env protetto, un YAML che non pubblica il database, healthcheck, un reverse proxy su 80/443, UFW, tag di immagine a cui tornare e dump Postgres che hai davvero ripristinato una volta. Parti dal modello di questo articolo, sostituisci l’immagine dell’app e tratta docker compose down -v come un comando distruttivo, con lo stesso rispetto di rm -rf.