Docker per Node.js: il setup production-ready di cui nessuno parla
Build multi-stage, utenti non-root, health check, gestione dei segreti e ottimizzazione della dimensione delle immagini. I pattern Docker che uso per ogni deployment Node.js in produzione.
La maggior parte dei Dockerfile Node.js in produzione è scadente. Non "leggermente subottimale" scadente. Intendo girare come root, spedire immagini da 600MB con le devDependencies incluse, nessun health check, e segreti hardcoded nelle variabili d'ambiente che chiunque con docker inspect può leggere.
Lo so perché scrivevo quei Dockerfile. Per anni. Funzionavano, quindi non li mettevo mai in discussione. Poi un giorno un audit di sicurezza ha segnalato il nostro container che girava come PID 1 root con accesso in scrittura all'intero filesystem, e ho realizzato che "funziona" e "production-ready" sono barre molto diverse.
Questo è il setup Docker che uso ora per ogni progetto Node.js. Non è teorico. Fa girare i servizi dietro questo sito e diversi altri che mantengo. Ogni pattern qui presente esiste perché o mi ci sono scottato con l'alternativa o ho visto qualcun altro scottarsi.
Perché il tuo Dockerfile attuale è probabilmente sbagliato#
Indovino com'è il tuo Dockerfile:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]Questo è il "hello world" dei Dockerfile. Funziona. Ha anche almeno cinque problemi che ti penalizzeranno in produzione.
Girare come root#
Per default, l'immagine Docker node gira come root. Questo significa che il processo della tua applicazione ha privilegi root all'interno del container. Se qualcuno sfrutta una vulnerabilità nella tua app — un bug di path traversal, una SSRF, una dipendenza con una backdoor — ha accesso root al filesystem del container, può modificare binari, installare pacchetti, e potenzialmente escalare ulteriormente a seconda della configurazione del tuo container runtime.
"Ma i container sono isolati!" Parzialmente. I container escape sono reali. CVE-2024-21626, CVE-2019-5736 — questi sono breakout reali di container. Girare come non-root è una misura di difesa in profondità. Non costa nulla e chiude un'intera classe di attacchi.
Installare le devDependencies in produzione#
npm install senza flag installa tutto. I tuoi test runner, linter, build tool, type checker — tutto presente nella tua immagine di produzione. Questo gonfia la tua immagine di centinaia di megabyte e aumenta la tua superficie di attacco. Ogni pacchetto aggiuntivo è un'altra potenziale vulnerabilità che Trivy o Snyk segnalerà.
Copiare tutto#
COPY . . copia l'intera directory del progetto nell'immagine. Questo include .git (che può essere enorme), file .env (che contengono segreti), node_modules (che stai per reinstallare comunque), file di test, documentazione, configurazioni CI — tutto.
Nessun health check#
Senza un'istruzione HEALTHCHECK, Docker non ha idea se la tua applicazione sta effettivamente servendo traffico. Il processo potrebbe essere in esecuzione ma in deadlock, senza memoria, o bloccato in un loop infinito. Docker riporterà il container come "running" perché il processo non è terminato. Il tuo load balancer continua a inviare traffico a un container zombie.
Nessuna strategia di layer caching#
Copiare tutto prima di installare le dipendenze significa che cambiare una singola riga di codice sorgente invalida la cache di npm install. Ogni build reinstalla tutte le dipendenze da zero. Su un progetto con dipendenze pesanti, sono 2-3 minuti di tempo sprecato per build.
Risolviamo tutto questo.
Build multi-stage: la singola vittoria più grande#
Le build multi-stage sono il cambiamento più impattante che puoi fare a un Dockerfile Node.js. Il concetto è semplice: usa uno stage per costruire la tua applicazione, poi copia solo gli artefatti di cui hai bisogno in un'immagine finale pulita e minimale.
Ecco la differenza in pratica:
# Single stage: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Multi-stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]Lo stage builder ha tutto: Node.js completo, npm, build tool, codice sorgente, devDependencies. Lo stage runner ha solo ciò che serve a runtime. Lo stage builder viene scartato interamente — non finisce nell'immagine finale.
Confronti reali di dimensione#
Ho misurato questi su un progetto API Express.js reale con circa 40 dipendenze:
| Approccio | Dimensione immagine |
|---|---|
node:20 + npm install | 1.1 GB |
node:20-slim + npm install | 420 MB |
node:20-alpine + npm ci | 280 MB |
| Multi-stage + alpine + solo dipendenze di produzione | 150 MB |
| Multi-stage + alpine + dipendenze potate | 95 MB |
Questa è una riduzione di 10x rispetto all'approccio naive. Immagini più piccole significano pull più veloci, deployment più rapidi, e meno superficie di attacco.
Perché Alpine?#
Alpine Linux usa musl libc invece di glibc, e non include una cache del package manager, documentazione, o la maggior parte delle utility che troveresti in una distribuzione Linux standard. L'immagine base node:20-alpine è circa 50MB rispetto ai 350MB di node:20-slim e oltre 1GB per la node:20 completa.
Il compromesso è che alcuni pacchetti npm con binding nativi (come bcrypt, sharp, canvas) devono essere compilati contro musl. Nella maggior parte dei casi funziona e basta — npm scaricherà il binario precompilato corretto. Se incontri problemi, puoi installare le dipendenze di build nello stage builder:
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... rest of buildQuesti build tool esistono solo nello stage builder. Non sono nella tua immagine finale.
Il Dockerfile di produzione completo#
Ecco il Dockerfile che uso come punto di partenza per ogni progetto Node.js. Ogni riga è intenzionale.
# ============================================
# Stage 1: Install dependencies
# ============================================
FROM node:20-alpine AS deps
# Security: create a working directory before anything else
WORKDIR /app
# Install dependencies based on lockfile
# Copy ONLY package files first — this is critical for layer caching
COPY package.json package-lock.json ./
# ci is better than install: it's faster, stricter, and reproducible
# --omit=dev excludes devDependencies from this stage
RUN npm ci --omit=dev
# ============================================
# Stage 2: Build the application
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files and install ALL dependencies (including dev)
COPY package.json package-lock.json ./
RUN npm ci
# NOW copy source code — changes here don't invalidate the npm ci cache
COPY . .
# Build the application (TypeScript compile, Next.js build, etc.)
RUN npm run build
# ============================================
# Stage 3: Production runner
# ============================================
FROM node:20-alpine AS runner
# Add labels for image metadata
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
# Security: install dumb-init for proper PID 1 signal handling
RUN apk add --no-cache dumb-init
# Security: set NODE_ENV before anything else
ENV NODE_ENV=production
# Security: use non-root user
# The node image already includes a 'node' user (uid 1000)
USER node
# Create app directory owned by node user
WORKDIR /app
# Copy production dependencies from deps stage
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Copy built application from builder stage
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
# Expose the port (documentation only — doesn't publish it)
EXPOSE 3000
# Health check: curl isn't available in alpine, use node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
# Use dumb-init as PID 1 to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start the application
CMD ["node", "dist/server.js"]Lascia che ti spieghi le parti che non sono ovvie.
Perché tre stage invece di due?#
Lo stage deps installa solo le dipendenze di produzione. Lo stage builder installa tutto (incluse le devDependencies) e costruisce l'app. Lo stage runner copia le dipendenze di produzione da deps e il codice compilato da builder.
Perché non installare le dipendenze di produzione nello stage builder? Perché lo stage builder ha le devDependencies mescolate. Dovresti eseguire npm prune --production dopo il build, il che è più lento e meno affidabile di avere un'installazione pulita delle sole dipendenze di produzione.
Perché dumb-init?#
Quando esegui node server.js in un container, Node.js diventa PID 1. PID 1 ha un comportamento speciale in Linux: non riceve i gestori di segnali predefiniti. Se invii SIGTERM al container (che è quello che fa docker stop), Node.js come PID 1 non lo gestirà per default. Docker aspetta 10 secondi, poi invia SIGKILL, che termina immediatamente il processo senza alcuna pulizia — nessun graceful shutdown, nessuna chiusura delle connessioni al database, nessun completamento delle richieste in corso.
dumb-init agisce come PID 1 e inoltra correttamente i segnali alla tua applicazione. Il tuo processo Node.js riceve SIGTERM come previsto e può chiudersi in modo pulito:
// server.js
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('HTTP server closed');
// Close database connections, flush logs, etc.
process.exit(0);
});
});Un'alternativa è il flag --init in docker run, ma incorporarlo nell'immagine significa che funziona indipendentemente da come il container viene avviato.
Il file .dockerignore#
Questo è importante tanto quanto il Dockerfile stesso. Senza di esso, COPY . . invia tutto al Docker daemon:
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*
Ogni file in .dockerignore è un file che non verrà inviato al build context, non finirà nella tua immagine, e non invaliderà la cache dei layer quando viene modificato.
Layer caching: smetti di aspettare 3 minuti per build#
Docker costruisce le immagini in layer. Ogni istruzione crea un layer. Se un layer non è cambiato, Docker usa la versione in cache. Ma ecco il dettaglio critico: se un layer cambia, tutti i layer successivi vengono invalidati.
Ecco perché l'ordine delle istruzioni è enormemente importante.
L'ordine sbagliato#
COPY . .
RUN npm ciOgni volta che cambi qualsiasi file — una singola riga in un singolo file sorgente — Docker vede che il layer COPY . . è cambiato. Invalida quel layer e tutto ciò che segue, incluso npm ci. Reinstalli tutte le dipendenze ad ogni cambio di codice.
L'ordine giusto#
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Ora npm ci gira solo quando package.json o package-lock.json cambiano. Se hai cambiato solo il codice sorgente, Docker riusa il layer npm ci dalla cache. Su un progetto con 500+ dipendenze, questo risparmia 60-120 secondi per build.
Cache mount per npm#
Docker BuildKit supporta i cache mount che persistono la cache npm tra le build:
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=devQuesto mantiene la cache di download di npm tra le build. Se una dipendenza era già stata scaricata in una build precedente, npm usa la versione in cache invece di scaricarla di nuovo. Questo è particolarmente utile in CI dove fai build frequentemente.
Per usare BuildKit, imposta la variabile d'ambiente:
DOCKER_BUILDKIT=1 docker build -t myapp .O aggiungi alla configurazione del Docker daemon:
{
"features": {
"buildkit": true
}
}Usare ARG per il cache busting#
A volte devi forzare un layer a ricostruirsi. Per esempio, se stai pullando un tag latest da un registry e vuoi assicurarti di ottenere la versione più recente:
ARG CACHE_BUST=1
RUN npm ciCostruisci con un valore univoco per forzare il busting della cache:
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .Usa questo con parsimonia. L'intero scopo del caching è la velocità — forza il cache busting solo quando hai un motivo.
Gestione dei segreti: smetti di mettere segreti nel tuo Dockerfile#
Questo è uno degli errori più comuni e pericolosi. Lo vedo costantemente:
# NEVER DO THIS
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456Le variabili d'ambiente impostate con ENV in un Dockerfile sono incorporate nell'immagine. Chiunque pulli l'immagine può vederle con docker inspect o docker history. Sono anche visibili in ogni layer dopo che vengono impostate. Anche se le fai unset dopo, esistono nella cronologia dei layer.
I tre livelli dei segreti#
1. Segreti build-time (Docker BuildKit)
Se hai bisogno di segreti durante il build (come un token di registry npm privato), usa il flag --secret di BuildKit:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Mount the secret at build time — it's never stored in the image
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci
COPY . .
RUN npm run buildCostruisci con:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .Il file .npmrc è disponibile durante il comando RUN ma non viene mai committato in alcun layer dell'immagine. Non appare in docker history o docker inspect.
2. Segreti runtime via variabili d'ambiente
Per i segreti di cui la tua applicazione ha bisogno a runtime, passali quando avvii il container:
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
-e API_KEY="sk-live-abc123" \
myappO con un file env:
docker run -d --env-file .env.production myappQuesti sono visibili tramite docker inspect sul container in esecuzione, ma non sono incorporati nell'immagine. Chiunque pulli l'immagine non ottiene i segreti.
3. Docker secrets (Swarm / Kubernetes)
Per una gestione dei segreti adeguata in ambienti orchestrati:
# docker-compose.yml (Swarm mode)
version: "3.8"
services:
api:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: trueDocker monta i segreti come file in /run/secrets/<secret_name>. La tua applicazione li legge dal filesystem:
import { readFileSync } from "fs";
function getSecret(name) {
try {
return readFileSync(`/run/secrets/${name}`, "utf8").trim();
} catch {
// Fall back to environment variable for local development
return process.env[name.toUpperCase()];
}
}
const dbPassword = getSecret("db_password");Questo è l'approccio più sicuro perché i segreti non appaiono mai nelle variabili d'ambiente, negli elenchi dei processi, o nell'output di ispezione del container.
File .env e Docker#
Non includere mai file .env nella tua immagine Docker. Il tuo .dockerignore dovrebbe escluderli (ecco perché abbiamo elencato .env e .env.* prima). Per lo sviluppo locale con docker-compose, montali a runtime:
services:
api:
env_file:
- .env.localHealth check: fai sapere a Docker che la tua app sta effettivamente funzionando#
Un health check dice a Docker se la tua applicazione sta funzionando correttamente. Senza uno, Docker sa solo se il processo è in esecuzione — non se è effettivamente in grado di gestire richieste.
L'istruzione HEALTHCHECK#
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"Analizziamo i parametri:
--interval=30s: Controlla ogni 30 secondi--timeout=10s: Se il check impiega più di 10 secondi, consideralo fallito--start-period=40s: Dai all'app 40 secondi per avviarsi prima di contare i fallimenti--retries=3: Segna come unhealthy dopo 3 fallimenti consecutivi
Perché non usare curl?#
Alpine non include curl per default. Potresti installarlo (apk add --no-cache curl), ma questo aggiunge un altro binario alla tua immagine minimale. Usare Node.js direttamente significa zero dipendenze aggiuntive.
Per health check ancora più leggeri, puoi usare uno script dedicato:
// healthcheck.js
const http = require("http");
const options = {
hostname: "localhost",
port: 3000,
path: "/health",
timeout: 5000,
};
const req = http.request(options, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
req.on("error", () => process.exit(1));
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD ["node", "healthcheck.js"]L'endpoint health#
La tua applicazione ha bisogno di un endpoint /health per il check. Non restituire semplicemente 200 — verifica effettivamente che la tua app sia sana:
app.get("/health", async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
status: "ok",
};
try {
// Check database connection
await db.query("SELECT 1");
checks.database = "connected";
} catch (err) {
checks.database = "disconnected";
checks.status = "degraded";
}
try {
// Check Redis connection
await redis.ping();
checks.redis = "connected";
} catch (err) {
checks.redis = "disconnected";
checks.status = "degraded";
}
const statusCode = checks.status === "ok" ? 200 : 503;
res.status(statusCode).json(checks);
});Uno status "degraded" con un 503 dice all'orchestratore di smettere di instradare traffico a questa istanza mentre si riprende, ma non necessariamente attiva un riavvio.
Perché gli health check contano per gli orchestratori#
Docker Swarm, Kubernetes, e anche il semplice docker-compose con restart: always usano gli health check per prendere decisioni:
- I load balancer smettono di inviare traffico ai container unhealthy
- I rolling update aspettano che il nuovo container sia healthy prima di fermare quello vecchio
- Gli orchestratori possono riavviare container che diventano unhealthy
- Le pipeline di deployment possono verificare che un deployment sia riuscito
Senza health check, un rolling deployment potrebbe uccidere il vecchio container prima che il nuovo sia pronto, causando downtime.
docker-compose per lo sviluppo#
Il tuo ambiente di sviluppo dovrebbe essere il più vicino possibile alla produzione, ma con la comodità di hot reload, debugger e feedback istantaneo. Ecco il setup docker-compose che uso per lo sviluppo:
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
args:
NODE_VERSION: "20"
ports:
- "3000:3000"
- "9229:9229" # Node.js debugger
volumes:
# Mount source code for hot reload
- .:/app
# Anonymous volume to preserve node_modules from the image
# This prevents the host's node_modules from overriding the container's
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
- REDIS_URL=redis://redis:6379
env_file:
- .env.local
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: devpassword
volumes:
# Named volume for persistent data across container restarts
- pgdata:/var/lib/postgresql/data
# Initialization scripts
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
command: redis-server --appendonly yes
# Optional: database admin UI
adminer:
image: adminer:latest
ports:
- "8080:8080"
depends_on:
- db
volumes:
pgdata:
redisdata:Pattern chiave per lo sviluppo#
Volume mount per hot reload: Il volume mount .:/app mappa il tuo codice sorgente locale nel container. Quando salvi un file, la modifica è immediatamente visibile all'interno del container. Combinato con un dev server che osserva le modifiche (come nodemon o tsx --watch), ottieni feedback istantaneo.
Il trucco di node_modules: Il volume anonimo - /app/node_modules assicura che il container usi i suoi node_modules (installati durante il build dell'immagine) piuttosto che i node_modules dell'host. Questo è fondamentale perché i moduli nativi compilati su macOS non funzioneranno all'interno di un container Linux.
Dipendenze tra servizi: depends_on con condition: service_healthy assicura che il database sia effettivamente pronto prima che la tua app tenti di connettersi. Senza la condizione health check, depends_on aspetta solo che il container si avvii — non che il servizio al suo interno sia pronto.
Named volumes: pgdata e redisdata persistono tra i riavvii dei container. Senza named volumes, perderesti il tuo database ogni volta che esegui docker-compose down.
Il Dockerfile di sviluppo#
Il tuo Dockerfile di sviluppo è più semplice di quello di produzione:
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
WORKDIR /app
# Install all dependencies (including devDependencies)
COPY package*.json ./
RUN npm ci
# Source code is mounted via volume, not copied
# But we still need it for the initial build
COPY . .
EXPOSE 3000 9229
CMD ["npm", "run", "dev"]Nessun build multi-stage, nessuna ottimizzazione per la produzione. L'obiettivo è l'iterazione veloce, non immagini piccole.
Docker Compose di produzione#
Il docker-compose di produzione è un'altra bestia. Ecco cosa uso:
# docker-compose.prod.yml
services:
app:
image: ghcr.io/yourorg/myapp:${TAG:-latest}
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env.production
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
replicas: 2
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- internal
- web
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
volumes:
pgdata:
driver: local
redisdata:
driver: local
networks:
internal:
driver: bridge
web:
external: trueCosa cambia rispetto allo sviluppo#
Policy di restart: unless-stopped riavvia il container automaticamente se crasha, a meno che tu non lo abbia fermato esplicitamente. Questo gestisce lo scenario "crash alle 3 di notte". L'alternativa always riavvierebbe anche i container che hai fermato intenzionalmente, che di solito non è ciò che vuoi.
Limiti delle risorse: Senza limiti, un memory leak nella tua app Node.js consumerà tutta la RAM disponibile sull'host, potenzialmente uccidendo altri container o l'host stesso. Imposta i limiti basandoti sull'utilizzo effettivo della tua applicazione più un po' di margine:
# Monitor actual usage to set appropriate limits
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"Configurazione dei log: Senza max-size e max-file, i log Docker crescono senza limiti. Ho visto server di produzione esaurire lo spazio disco a causa dei log Docker. json-file con rotazione è la soluzione più semplice. Per logging centralizzato, passa al driver fluentd o gelf:
logging:
driver: "fluentd"
options:
fluentd-address: "localhost:24224"
tag: "myapp.{{.Name}}"Isolamento di rete: La rete internal è accessibile solo ai servizi in questo stack compose. Il database e Redis non sono esposti all'host o ad altri container. Solo il servizio app è connesso alla rete web, che il tuo reverse proxy (Nginx, Traefik) usa per instradare il traffico.
Nessun port mapping per i database: Nota che db e redis non hanno ports nella configurazione di produzione. Sono accessibili solo tramite la rete Docker interna. In sviluppo, li esponiamo così possiamo usare strumenti locali (pgAdmin, Redis Insight). In produzione, non c'è motivo che siano accessibili dall'esterno della rete Docker.
Specifico per Next.js: l'output standalone#
Next.js ha un'ottimizzazione Docker integrata che molte persone non conoscono: la modalità di output standalone. Traccia gli import della tua applicazione e copia solo i file necessari per l'esecuzione — nessun node_modules richiesto (le dipendenze sono incluse nel bundle).
Abilitala in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;Questo cambia drasticamente l'output del build. Invece di aver bisogno dell'intera directory node_modules, Next.js produce un server.js autocontenuto in .next/standalone/ che include solo le dipendenze che usa effettivamente.
Il Dockerfile di produzione per Next.js#
Questo è il Dockerfile che uso per progetti Next.js, basato sull'esempio ufficiale Vercel ma con hardening di sicurezza:
# ============================================
# Stage 1: Install dependencies
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ============================================
# Stage 2: Build the application
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ============================================
# Stage 3: Production runner
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy public assets
COPY --from=builder /app/public ./public
# Set up the standalone output directory
# Automatically leverages output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copy the standalone server and static files
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]Confronto dimensioni per Next.js#
| Approccio | Dimensione immagine |
|---|---|
node:20 + node_modules completi + .next | 1.4 GB |
node:20-alpine + node_modules completi + .next | 600 MB |
node:20-alpine + output standalone | 120 MB |
L'output standalone è trasformativo. Un'immagine da 1.4 GB diventa 120 MB. Deploy che impiegavano 90 secondi per il pull ora ne impiegano 10.
Gestione dei file statici#
La modalità standalone di Next.js non include la cartella public o gli asset statici da .next/static. Devi copiarli esplicitamente (cosa che facciamo nel Dockerfile sopra). In produzione, tipicamente vuoi una CDN davanti a questi:
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
assetPrefix: process.env.CDN_URL || undefined,
};Se non usi una CDN, Next.js serve i file statici direttamente. Il server standalone gestisce questo senza problemi — devi solo assicurarti che i file siano nel posto giusto (cosa che il nostro Dockerfile garantisce).
Sharp per l'ottimizzazione delle immagini#
Next.js usa sharp per l'ottimizzazione delle immagini. Nell'immagine di produzione basata su Alpine, devi assicurarti che il binario corretto sia disponibile:
# In the runner stage, before switching to non-root user
RUN apk add --no-cache --virtual .sharp-deps vips-devO meglio, installalo come dipendenza di produzione e lascia che npm gestisca il binario specifico per la piattaforma:
npm install sharpL'immagine node:20-alpine funziona con il binario precompilato linux-x64-musl di sharp. Nessuna configurazione speciale necessaria nella maggior parte dei casi.
Scansione delle immagini e sicurezza#
Costruire un'immagine piccola con un utente non-root è un buon inizio, ma non è sufficiente per workload di produzione seri. Ecco come andare oltre.
Trivy: scansiona le tue immagini#
Trivy è uno scanner completo di vulnerabilità per immagini container. Eseguilo nella tua pipeline CI:
# Install trivy
brew install aquasecurity/trivy/trivy # macOS
# or
apt-get install trivy # Debian/Ubuntu
# Scan your image
trivy image myapp:latestOutput di esempio:
myapp:latest (alpine 3.19.1)
=============================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Node.js (node_modules/package-lock.json)
=========================================
Total: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
┌──────────────┬────────────────┬──────────┬────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────┤
│ semver │ CVE-2022-25883 │ HIGH │ fixed │ 7.5.4 │
│ word-wrap │ CVE-2023-26115 │ MEDIUM │ fixed │ 1.2.4 │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘
Integralo nel CI per far fallire le build su vulnerabilità critiche:
# .github/workflows/docker.yml
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: trueFilesystem read-only#
Puoi eseguire container con un filesystem root in sola lettura. Questo impedisce a un attaccante di modificare binari, installare strumenti, o scrivere script malevoli:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /app/.next/cache \
myapp:latestI mount --tmpfs forniscono directory temporanee scrivibili dove la tua applicazione ha legittimamente bisogno di scrivere (file temporanei, cache). Tutto il resto è in sola lettura.
In docker-compose:
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/.next/cacheRimuovere tutte le capabilities#
Le Linux capabilities sono permessi granulari che sostituiscono il modello tutto-o-niente di root. Per default, i container Docker ottengono un sottoinsieme di capabilities. Puoi rimuoverle tutte:
docker run --cap-drop=ALL myapp:latestSe la tua applicazione ha bisogno di fare bind su una porta sotto 1024, avresti bisogno di NET_BIND_SERVICE. Ma siccome usiamo la porta 3000 con un utente non-root, non abbiamo bisogno di nessuna capability:
services:
app:
image: myapp:latest
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueno-new-privileges impedisce al processo di ottenere privilegi aggiuntivi attraverso binari setuid/setgid. Questa è una misura di difesa in profondità che non costa nulla.
Pinnare il digest dell'immagine base#
Invece di usare node:20-alpine (che è un bersaglio mobile), pinna a un digest specifico:
FROM node:20-alpine@sha256:abcdef123456...Ottieni il digest con:
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpineQuesto assicura che le tue build siano riproducibili al 100%. Il compromesso è che non ottieni automaticamente le patch di sicurezza dell'immagine base. Usa Dependabot o Renovate per automatizzare gli aggiornamenti dei digest:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: "/"
schedule:
interval: weeklyIntegrazione CI/CD: mettere tutto insieme#
Ecco un workflow GitHub Actions completo che costruisce, scansiona e pusha un'immagine Docker:
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload Trivy results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifCache BuildKit nel CI#
Le righe cache-from: type=gha e cache-to: type=gha,mode=max usano la cache di GitHub Actions come cache per i layer Docker. Questo significa che le tue build CI beneficiano del layer caching tra le esecuzioni. La prima build impiega 5 minuti; le build successive con solo cambiamenti al codice impiegano 30 secondi.
Insidie comuni e come evitarle#
Il conflitto tra node_modules nell'immagine e sull'host#
Se fai il volume-mount della directory del progetto in un container (-v .:/app), i node_modules dell'host sovrascrivono quelli del container. I moduli nativi compilati su macOS non funzioneranno in Linux. Usa sempre il trucco del volume anonimo:
volumes:
- .:/app
- /app/node_modules # preserves container's node_modulesGestione SIGTERM nei progetti TypeScript#
Se stai eseguendo TypeScript con tsx o ts-node in sviluppo, la gestione dei segnali funziona normalmente. Ma in produzione, se stai usando il JavaScript compilato con node, assicurati che l'output compilato preservi i gestori dei segnali. Alcuni build tool ottimizzano via il codice "non usato".
Limiti di memoria e Node.js#
Node.js non rispetta automaticamente i limiti di memoria Docker. Se il tuo container ha un limite di memoria di 512MB, Node.js cercherà comunque di usare la dimensione heap predefinita (circa 1.5 GB su sistemi a 64-bit). Imposta il max old space size:
CMD ["node", "--max-old-space-size=384", "dist/server.js"]Lascia circa il 25% di margine tra il limite heap di Node.js e il limite di memoria del container per la memoria non-heap (buffer, codice nativo, ecc.).
O usa il flag di rilevamento automatico:
ENV NODE_OPTIONS="--max-old-space-size=384"Problemi di fuso orario#
Alpine usa UTC per default. Se la tua applicazione dipende da un fuso orario specifico:
RUN apk add --no-cache tzdata
ENV TZ=America/New_YorkMa meglio ancora: scrivi codice agnostico rispetto al fuso orario. Salva tutto in UTC. Converti all'ora locale solo al livello di presentazione.
Build argument vs variabili d'ambiente#
ARGè disponibile solo durante il build. Non persiste nell'immagine finale (a meno che tu non lo copi inENV).ENVpersiste nell'immagine ed è disponibile a runtime.
# Build-time configuration
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# Runtime configuration
ENV PORT=3000
# WRONG: This makes the secret visible in the image
ARG API_KEY
ENV API_KEY=${API_KEY}
# RIGHT: Pass secrets at runtime
# docker run -e API_KEY=secret myappMonitoraggio in produzione#
Il tuo setup Docker non è completo senza osservabilità. Ecco uno stack di monitoraggio minimale ma efficace:
# docker-compose.monitoring.yml
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
networks:
- internal
grafana:
image: grafana/grafana:latest
volumes:
- grafana_data:/var/lib/grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
networks:
- internal
volumes:
prometheus_data:
grafana_data:Esponi le metriche dalla tua app Node.js usando prom-client:
import { collectDefaultMetrics, Registry, Histogram } from "prom-client";
const register = new Registry();
collectDefaultMetrics({ register });
const httpRequestDuration = new Histogram({
name: "http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
registers: [register],
});
// Middleware
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on("finish", () => {
end({ method: req.method, route: req.route?.path || req.path, status_code: res.statusCode });
});
next();
});
// Metrics endpoint
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
});La checklist#
Prima di spedire un'app Node.js containerizzata in produzione, verifica:
- Utente non-root — Il container gira come utente non-root
- Build multi-stage — devDependencies e build tool non sono nell'immagine finale
- Base Alpine — Usa un'immagine base minimale
- .dockerignore —
.git,.env,node_modules, test esclusi - Layer caching —
package.jsoncopiato prima del codice sorgente - Health check — Istruzione HEALTHCHECK nel Dockerfile
- Gestione dei segnali —
dumb-inito--initper la corretta gestione di SIGTERM - Nessun segreto nell'immagine — Nessun
ENVcon valori sensibili nel Dockerfile - Limiti delle risorse — Limiti di memoria e CPU impostati in compose/orchestratore
- Rotazione dei log — Driver di logging configurato con dimensione massima
- Scansione delle immagini — Trivy o equivalente nella pipeline CI
- Versioni pinnate — Immagine base e versioni delle dipendenze pinnate
- Limiti di memoria —
--max-old-space-sizeimpostato per l'heap Node.js
La maggior parte di questi sono setup una tantum. Fallo una volta, crea un template, e ogni nuovo progetto parte con un container production-ready dal primo giorno.
Docker non è complicato. Ma il divario tra un Dockerfile "funzionante" e uno production-ready è più ampio di quanto la maggior parte delle persone pensi. I pattern in questa guida colmano quel divario. Usali, adattali, e smetti di deployare container root con immagini da 1GB e nessun health check. Il tuo futuro te stesso — quello che viene svegliato alle 3 di notte — ti ringrazierà.