Per avere Node e PostgreSQL in locale con Docker Compose servono un Dockerfile per l’app, un compose.yaml con il database, il suo healthcheck e un volume per i dati, e un file .env con le credenziali che non finisce nel repository. Con depends_on e condition: service_healthy l’app parte solo quando Postgres accetta connessioni, e con un file di override aggiungi in sviluppo il codice montato dall’host e il riavvio automatico. La parte che conta di più, però, è un’altra: costruire tutto in modo che la stessa immagine e le stesse variabili valgano anche per staging e produzione.
Questa guida parte da lì. Il mio ambiente di sviluppo è un’immagine Docker locale in cui provo le modifiche prima che vadano avanti; staging e live stanno su VPS e sono collegati da una pipeline su GitHub Actions: ogni push va su dev e viene testato, poi passa a staging, e il passaggio a live lo avvio io a mano. Il codice lo scrivo quasi tutto con Claude Code e uso spesso Codex per testarlo, quindi l’ambiente locale deve essere qualcosa che si avvia e si butta via in un comando. La configurazione che trovi sotto è un esempio da cui partire e da adattare al tuo progetto; tutti i file sono stati provati da una cartella vuota con Docker 28, Compose 2.37, node:24-alpine e postgres:18-alpine.
Indice
- Da una cartella vuota alla struttura del progetto
- Il Dockerfile: un’immagine sola per tutti gli ambienti
- compose.yaml: database, healthcheck e ruoli separati
- Il file .env fuori dal repository
- Il codice montato in sviluppo
- Le migrazioni e il server Node
- I comandi di tutti i giorni
- Errori comuni
- Dallo sviluppo locale allo staging su VPS
- Prossimi passi
- Fonti e documentazione
Da una cartella vuota alla struttura del progetto
L’esempio è volutamente piccolo: un server HTTP in Node che si collega a Postgres con il pacchetto pg, una route /health che interroga il database e uno script di migrazione. Senza framework, così l’attenzione resta su Compose. Prima di scrivere i file Docker servono package.json e package-lock.json, perché il Dockerfile installa le dipendenze dal lockfile:
mkdir myapp && cd myapp
npm init -y
npm pkg set type=module
npm install pg
type=module permette di usare import nei file .js, e npm install pg mette il driver tra le dipendenze di produzione e genera il lockfile. Se sull’host non hai Node, puoi lanciare gli stessi comandi in un container usa e getta: docker run --rm -v "$PWD":/app -w /app node:24-alpine sh -c "npm init -y && npm pkg set type=module && npm install pg". A questo punto la cartella avrà questa forma:
myapp/
├── compose.yaml # base comune a dev, staging e live
├── compose.override.yaml # solo sviluppo: porta del db, bind mount, node --watch
├── compose.watch.yaml # alternativa al bind mount con Compose Watch
├── Dockerfile
├── .dockerignore
├── .gitignore
├── .env # credenziali locali, mai nel repository
├── .env.example # le stesse chiavi senza valori: questo sì nel repository
├── package.json
├── package-lock.json
├── migrations/
│ └── 001_notes.sql
└── src/
├── migrate.js
└── server.js
La divisione tra compose.yaml e compose.override.yaml è il punto di tutto l’articolo. Quando lanci docker compose up senza opzioni, Compose legge entrambi i file e unisce l’override sopra la base. Su staging, invece, si usa solo la base con -f compose.yaml: niente porta del database esposta, niente codice montato dall’host, solo l’immagine.
Il Dockerfile: un’immagine sola per tutti gli ambienti
File Dockerfile:
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node src ./src
COPY --chown=node:node migrations ./migrations
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
- Tag esplicito.
node:24-alpinepunta a Node 24, una release LTS, invece dinode:latest, che un giorno cambia major version senza che tu abbia toccato niente. Fissare anche la minor (node:24.22-alpine) riduce gli aggiornamenti che accetti, ma non rende la base immutabile: lo stesso tag viene ripubblicato con patch e versioni di Alpine nuove. Per identificare esattamente l’immagine serve il digest,node:24-alpine@sha256:..., che leggi condocker buildx imagetools inspect node:24-alpine. npm cicon il lockfile. Installa esattamente le versioni delpackage-lock.json, e copiare i due file prima del codice sfrutta la cache dei layer: finché le dipendenze non cambiano, la build salta l’installazione.USER nodee--chown. L’immagine ufficiale ha già un utente non privilegiato. I file copiati appartengono anode, così il processo non gira come root e Compose Watch, più avanti, può aggiornarli dentro il container.- Niente devDependencies. È l’immagine che andrà su staging. Se ti servono strumenti di sviluppo, la strada pulita è una build multi-stage con un target
dev, non un’immagine diversa per ogni ambiente.
File .dockerignore:
node_modules
.env
deploy.env
.git
Il .dockerignore tiene node_modules, il repository git e i file con le variabili fuori dal contesto di build. Con i COPY selettivi di questo Dockerfile il .env non finirebbe comunque nell’immagine, ma così non arriva nemmeno al builder ed è escluso anche il giorno in cui qualcuno scrive un COPY . ..
compose.yaml: database, healthcheck e ruoli separati
File compose.yaml:
name: myapp
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?imposta POSTGRES_PASSWORD nel file .env}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
migrate:
image: ${APP_IMAGE:-myapp:dev}
build: .
command: ["node", "src/migrate.js"]
environment:
PGHOST: db
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
APP_DB_USER: ${APP_DB_USER}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?imposta APP_DB_PASSWORD nel file .env}
depends_on:
db:
condition: service_healthy
app:
image: ${APP_IMAGE:-myapp:dev}
build: .
environment:
PGHOST: db
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${APP_DB_USER}
PGPASSWORD: ${APP_DB_PASSWORD}
PORT: 3000
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
volumes:
pgdata:
Non c’è la chiave version: in cima: nella specifica Compose attuale è obsoleta, viene ignorata e produce solo un avviso. name: myapp diventa il prefisso di container, rete e volumi (myapp-db-1, myapp_pgdata).
Gli healthcheck. depends_on da solo aspetta che il container del database sia avviato, non che Postgres sia pronto. Con pg_isready e condition: service_healthy Compose aspetta lo stato healthy. L’opzione -h 127.0.0.1 fa il controllo via TCP: alla prima inizializzazione l’immagine avvia un server temporaneo raggiungibile solo dal socket locale, e senza -h il controllo potrebbe passare prima che il database accetti le connessioni di rete. Il doppio dollaro in $${POSTGRES_USER} lascia l’espansione alla shell del container. Anche app ha un healthcheck, che chiama /health con il fetch integrato in Node: così docker compose up --wait si ferma solo quando l’app risponde davvero.
Il volume. pgdata è un volume con nome, quindi i dati sopravvivono quando ricrei il container. Da PostgreSQL 18 l’immagine ufficiale lo dichiara su /var/lib/postgresql e salva i dati in una sottocartella per versione; con la 17 o precedenti il punto di mount resta /var/lib/postgresql/data. I due percorsi non sono intercambiabili: con la 18 montata sul percorso vecchio il container esce subito con un errore che spiega il nuovo layout, mentre con la 17 montata su /var/lib/postgresql i dati non persistono quando il container viene ricreato.
Due ruoli. POSTGRES_USER crea il superutente dell’immagine, qualunque nome gli dai. Lo usa solo migrate, che applica lo schema e crea il ruolo dell’app; il server HTTP si collega con APP_DB_USER, che può leggere e scrivere i dati ma non creare o cancellare tabelle. Se un giorno l’app ha una SQL injection, il danno resta limitato a quei permessi: niente DROP, niente ALTER, niente nuovi ruoli.
Variabili separate. Le credenziali arrivano come PGHOST, PGUSER, PGPASSWORD e così via, le variabili standard che pg legge da solo. Concatenarle in una URI come postgres://utente:password@db/... funziona finché la password non contiene #, / o @, che rompono il parsing.
Le stesse variabili ovunque. APP_IMAGE vale myapp:dev se non la imposti; su staging diventa il riferimento all’immagine costruita in CI e il file resta identico. La porta dell’app è pubblicata solo su 127.0.0.1, in locale come sulla VPS.
Il file .env fuori dal repository
Compose legge in automatico il file .env nella cartella del progetto e lo usa per l’interpolazione delle variabili. Nel repository va solo .env.example, con le chiavi e senza valori segreti, e nel .gitignore ci sono node_modules, .env e deploy.env, che userai per lo staging:
File .env.example:
# superutente: lo usa solo il servizio migrate
POSTGRES_USER=admin
POSTGRES_PASSWORD=
POSTGRES_DB=app_dev
# ruolo con privilegi minimi: lo usa l'app
APP_DB_USER=app
APP_DB_PASSWORD=
APP_PORT=3000
DB_PORT=5432
La sintassi ${POSTGRES_PASSWORD:?messaggio} fa fallire subito docker compose config e up se la variabile manca, invece di avviare un database con la password vuota. Su staging e live il file .env esiste solo sulla VPS, oppure le variabili arrivano dai secret della pipeline: non passano mai dal repository. Lo stesso vale per le chiavi con cui l’app firma o verifica le richieste, come i segreti JWT della guida su autenticazione API con API key, JWT e OAuth2.
Il codice montato in sviluppo
File compose.override.yaml:
services:
db:
ports:
- "127.0.0.1:${DB_PORT:-5432}:5432"
migrate:
volumes:
- ./src:/app/src
- ./migrations:/app/migrations
app:
command: ["node", "--watch", "src/server.js"]
volumes:
- ./src:/app/src
L’override aggiunge tre cose che servono solo in locale. La porta di Postgres su 127.0.0.1, per collegarti con psql o DBeaver senza aprirla alla rete. Il bind mount della sola cartella src: se monti tutto il progetto su /app, la node_modules dell’host (o la sua assenza) copre quella installata nell’immagine e l’app non trova pg. E node --watch, il watch mode integrato in Node: niente nodemon, il processo riparte quando cambia un file.
Un limite emerso provando questa configurazione su Docker Desktop per Windows, con i file sul disco di Windows: il file modificato arrivava nel container, ma node --watch non ripartiva, perché gli eventi di modifica non attraversano il bind mount. In questi casi c’è Compose Watch, che copia i file nel container e riavvia il servizio da solo:
File compose.watch.yaml:
services:
db:
ports:
- "127.0.0.1:${DB_PORT:-5432}:5432"
app:
develop:
watch:
- action: sync+restart
path: ./src
target: /app/src
- action: rebuild
path: package.json
- action: rebuild
path: package-lock.json
Lo avvii con docker compose -f compose.yaml -f compose.watch.yaml up --watch, senza l’override, così il bind mount non si sovrappone al percorso sincronizzato. La porta dell’app arriva dalla base, quella del database da questo file. Una modifica in src viene sincronizzata e seguita da un riavvio, mentre una modifica a package.json o package-lock.json ricostruisce l’immagine. Compose Watch funziona solo sui servizi con una chiave build, motivo per cui anche app ce l’ha.
Le migrazioni e il server Node
Lo script di migrazione è il minimo indispensabile: un registro dei file già applicati, ogni file SQL in una transazione, uscita con codice diverso da zero se qualcosa va storto. Alla fine crea il ruolo dell’app, o ne aggiorna la password, e gli concede i privilegi sulle tabelle dello schema public: girando a ogni avvio, copre anche le tabelle aggiunte dalle migrazioni successive. Il registro sta invece nello schema migrations, di proprietà del superutente e senza alcun permesso per il ruolo dell’app: se il registro fosse in public, quel GRANT ... ON ALL TABLES permetterebbe all’app di cancellarlo o falsificarlo. In un progetto vero userai probabilmente uno strumento dedicato o quello del tuo ORM, ma il contratto con Compose non cambia: un comando che applica le migrazioni ed esce.
File src/migrate.js:
import { readdir, readFile } from 'node:fs/promises';
import pg from 'pg';
const dir = new URL('../migrations/', import.meta.url);
const client = new pg.Client(); // qui PGUSER è il superutente
await client.connect();
try {
// Registro in uno schema riservato: il ruolo dell'app non ha USAGE qui.
await client.query('CREATE SCHEMA IF NOT EXISTS migrations');
await client.query(`CREATE TABLE IF NOT EXISTS migrations.schema_migrations (
name text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)`);
const { rows } = await client.query('SELECT name FROM migrations.schema_migrations');
const applied = new Set(rows.map((r) => r.name));
for (const file of (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort()) {
if (applied.has(file)) continue;
const sql = await readFile(new URL(file, dir), 'utf8');
await client.query('BEGIN');
try {
await client.query(sql);
await client.query('INSERT INTO migrations.schema_migrations (name) VALUES ($1)', [file]);
await client.query('COMMIT');
console.log(`Applicata ${file}`);
} catch (err) {
await client.query('ROLLBACK');
throw new Error(`${file}: ${err.message}`);
}
}
// Ruolo dell'app: login e lettura/scrittura dei dati, nessun DDL.
const user = process.env.APP_DB_USER;
const role = client.escapeIdentifier(user);
const password = client.escapeLiteral(process.env.APP_DB_PASSWORD);
const db = client.escapeIdentifier(process.env.PGDATABASE);
const exists = (await client.query('SELECT 1 FROM pg_roles WHERE rolname = $1', [user])).rowCount;
await client.query(`${exists ? 'ALTER' : 'CREATE'} ROLE ${role} LOGIN PASSWORD ${password}`);
await client.query(`GRANT CONNECT ON DATABASE ${db} TO ${role}`);
await client.query(`GRANT USAGE ON SCHEMA public TO ${role}`);
await client.query(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${role}`);
await client.query(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${role}`);
console.log(`Privilegi aggiornati per ${user}`);
} catch (err) {
console.error('Migrazione fallita:', err.message);
process.exitCode = 1;
} finally {
await client.end();
}
File migrations/001_notes.sql:
CREATE TABLE notes (
id serial PRIMARY KEY,
body text NOT NULL
);
INSERT INTO notes (body) VALUES ('prima nota');
Il server risponde su /health solo se riesce a fare SELECT 1, quindi la route serve sia all’healthcheck sia ai controlli dopo un deploy. Ogni errore del database diventa una risposta 503 invece di un crash, e pool.on('error') intercetta le connessioni inattive che cadono, per esempio quando Postgres si riavvia. Alla ricezione di SIGTERM chiude le connessioni prima di uscire:
File src/server.js:
import http from 'node:http';
import pg from 'pg';
// new pg.Pool() legge PGHOST, PGPORT, PGUSER, PGPASSWORD e PGDATABASE
const pool = new pg.Pool();
pool.on('error', (err) => console.error('Connessione inattiva persa:', err.message));
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
const server = http.createServer(async (req, res) => {
try {
if (req.url === '/health') {
await pool.query('SELECT 1');
return json(res, 200, { status: 'ok', db: 'up' });
}
if (req.url === '/notes') {
const { rows } = await pool.query('SELECT id, body FROM notes ORDER BY id');
return json(res, 200, rows);
}
json(res, 404, { error: 'not found' });
} catch (err) {
console.error(`${req.url}:`, err.message);
json(res, 503, { status: 'error', db: 'down' });
}
});
server.listen(process.env.PORT ?? 3000, () => {
console.log(`In ascolto sulla porta ${server.address().port}`);
});
process.on('SIGTERM', () => {
server.close(() => pool.end().then(() => process.exit(0)));
});
PGHOST vale db, cioè il nome del servizio: dentro la rete di Compose ogni servizio è raggiungibile con il suo nome. Quando l’API cresce, la stessa struttura regge endpoint con paginazione e filtri come quelli della guida su REST API scalabili: pagination, filtri, ordinamento e versioning.
I comandi di tutti i giorni
# avvio in background, con build e attesa degli healthcheck
docker compose up -d --build --wait
# stato dei servizi (anche quelli usciti, come migrate)
docker compose ps -a
# log dell'app in tempo reale
docker compose logs -f app
# psql dentro il container del database, come superutente
docker compose exec db psql -U admin -d app_dev
# riapplica le migrazioni dopo aver aggiunto un file SQL
docker compose run --rm migrate
# ferma e rimuove container e rete, i dati restano nel volume
docker compose down
# rimuove anche i volumi: il database riparte vuoto
docker compose down -v
docker compose down rimuove container e reti ma lascia i volumi con nome, quindi il database è lì al prossimo up. down -v cancella anche myapp_pgdata: va bene quando vuoi ripartire da zero, per esempio per verificare che le migrazioni ricostruiscano lo schema su un database vuoto. Non usarlo per abitudine come “spegni tutto”, e mai su una VPS dove lo stesso file Compose gestisce dati che non puoi ricreare.
Errori comuni
Porta già occupata. Se sull’host c’è già un Postgres o un altro progetto sulla stessa porta, l’avvio si ferma con un messaggio come Bind for 127.0.0.1:5432 failed: port is already allocated. Non serve toccare il file: cambi DB_PORT o APP_PORT nel .env e rilanci. La porta interna resta la stessa, cambia solo quella pubblicata sull’host.
localhost al posto del nome del servizio. Dentro un container localhost è il container stesso: con PGHOST=localhost l’app non trova il database. Da dentro Compose l’host è db; da un client sul tuo computer è localhost con la porta pubblicata. Sono due configurazioni diverse ed è normale.
Dati spariti dopo un riavvio. Le cause sono quasi sempre tre: hai usato down -v, con Postgres 17 o precedenti il volume è montato su /var/lib/postgresql invece che su /var/lib/postgresql/data, oppure hai cambiato name: del progetto e Compose ha creato un volume nuovo con un altro prefisso. docker volume ls ti dice quale caso è.
Gli script di inizializzazione non partono. Gli script in /docker-entrypoint-initdb.d dell’immagine Postgres girano solo quando la cartella dei dati è vuota. Con un volume già popolato vengono ignorati: è il motivo per cui qui schema e ruolo dell’app passano dal servizio di migrazione.
Permessi dei volumi. Per i dati di Postgres usa un volume con nome e non una cartella dell’host: su Docker Desktop il bind mount della cartella dati può far fallire l’inizializzazione per problemi di permessi. Lo stesso vale per il codice: il processo gira come utente node (UID 1000) e, se deve scrivere nel bind mount, su Linux l’utente dell’host deve coincidere, altrimenti ottieni EACCES. In quel caso imposti user: nell’override con UID e GID del tuo utente.
Dallo sviluppo locale allo staging su VPS
Il motivo per cui la base è separata dall’override è questo passaggio. In CI la pipeline costruisce l’immagine una volta sola e la pubblica su un registry; sulla VPS di staging lo stesso compose.yaml la scarica e la avvia. Conviene tenere separate due cose che cambiano a ritmi diversi: il .env con le credenziali, scritto una volta sul server, e un deploy.env con il solo riferimento all’immagine, che la pipeline riscrive a ogni rilascio:
# deploy.env, scritto dalla pipeline
APP_IMAGE=ghcr.io/tuo-utente/myapp@sha256:DIGEST_DELLA_BUILD
# sulla VPS, nella cartella con compose.yaml, .env e deploy.env
docker compose --env-file .env --env-file deploy.env -f compose.yaml pull
docker compose --env-file .env --env-file deploy.env -f compose.yaml up -d --no-build --wait
Quando passi --env-file, Compose non legge più il .env in automatico: per questo vanno indicati entrambi. APP_IMAGE contiene il riferimento completo, con il digest o almeno un tag univoco: il digest garantisce che su live arrivi esattamente l’immagine verificata su staging, perché un tag può essere ripubblicato. Il servizio migrate gira prima dell’app anche lì, con le migrazioni che hai provato in locale.
Dall’esterno l’app non si raggiunge direttamente, perché la porta è pubblicata solo su 127.0.0.1: sulla VPS un reverse proxy installato sull’host, come Nginx o Caddy, riceve le richieste HTTPS e le inoltra a 127.0.0.1:3000. Il workflow GitHub Actions della guida alla pipeline CI/CD con GitHub Actions per Node e React è un esempio separato, con le sue variabili: per usarlo con questo progetto fai scrivere alla pipeline il digest dell’immagine in deploy.env come APP_IMAGE e lascia intatto il .env delle credenziali. Il modo in cui la catena dev, staging e live si incastra con gli strumenti di AI che uso ogni giorno è raccontato nell’articolo sull’AI nel workflow di sviluppo.
Prossimi passi
- Crea la cartella con i comandi
npmdell’inizio, copia i file, crea il.envda.env.examplecon due password diverse e lanciadocker compose up -d --build --wait; poi aprihttp://localhost:3000/health. - Aggiungi una seconda migrazione in
migrations/, lanciadocker compose run --rm migratee controllamigrations.schema_migrationsconpsqlcome superutente. - Prova una volta
docker compose down -vseguito daup, per essere sicuro che schema e ruolo si ricostruiscano solo con le migrazioni. - Annota nel README la versione di Postgres che usi, così il percorso del volume resta coerente, e valuta di fissare la base del
Dockerfilecon il digest. - Collega il repository a una pipeline che costruisce l’immagine e scrive
deploy.envsu staging: da quel momento il file Compose locale e quello del server sono lo stesso file. Altri strumenti da affiancare a questo ambiente li trovi nella pagina degli strumenti per sviluppatori.








