Vai al contenuto
·25 min di lettura

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.

Condividi:X / TwitterLinkedIn

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:

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:

dockerfile
# 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:

ApproccioDimensione immagine
node:20 + npm install1.1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 MB
Multi-stage + alpine + solo dipendenze di produzione150 MB
Multi-stage + alpine + dipendenze potate95 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:

dockerfile
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... rest of build

Questi 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.

dockerfile
# ============================================
# 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:

javascript
// 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#

dockerfile
COPY . .
RUN npm ci

Ogni 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#

dockerfile
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:

dockerfile
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

Questo 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:

bash
DOCKER_BUILDKIT=1 docker build -t myapp .

O aggiungi alla configurazione del Docker daemon:

json
{
  "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:

dockerfile
ARG CACHE_BUST=1
RUN npm ci

Costruisci con un valore univoco per forzare il busting della cache:

bash
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:

dockerfile
# NEVER DO THIS
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456

Le 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:

dockerfile
# 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 build

Costruisci con:

bash
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:

bash
docker run -d \
  -e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
  -e API_KEY="sk-live-abc123" \
  myapp

O con un file env:

bash
docker run -d --env-file .env.production myapp

Questi 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:

yaml
# 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: true

Docker monta i segreti come file in /run/secrets/<secret_name>. La tua applicazione li legge dal filesystem:

javascript
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:

yaml
services:
  api:
    env_file:
      - .env.local

Health 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#

dockerfile
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:

javascript
// 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();
dockerfile
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:

javascript
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:

yaml
# 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
# 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:

yaml
# 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: true

Cosa 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:

bash
# 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:

yaml
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:

typescript
// 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:

dockerfile
# ============================================
# 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#

ApproccioDimensione immagine
node:20 + node_modules completi + .next1.4 GB
node:20-alpine + node_modules completi + .next600 MB
node:20-alpine + output standalone120 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:

typescript
// 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:

dockerfile
# In the runner stage, before switching to non-root user
RUN apk add --no-cache --virtual .sharp-deps vips-dev

O meglio, installalo come dipendenza di produzione e lascia che npm gestisca il binario specifico per la piattaforma:

bash
npm install sharp

L'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:

bash
# Install trivy
brew install aquasecurity/trivy/trivy  # macOS
# or
apt-get install trivy  # Debian/Ubuntu
 
# Scan your image
trivy image myapp:latest

Output 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:

yaml
# .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: true

Filesystem 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:

bash
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/.next/cache \
  myapp:latest

I 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:

yaml
services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /app/.next/cache

Rimuovere 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:

bash
docker run --cap-drop=ALL myapp:latest

Se 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:

yaml
services:
  app:
    image: myapp:latest
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

no-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:

dockerfile
FROM node:20-alpine@sha256:abcdef123456...

Ottieni il digest con:

bash
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine

Questo 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:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: docker
    directory: "/"
    schedule:
      interval: weekly

Integrazione CI/CD: mettere tutto insieme#

Ecco un workflow GitHub Actions completo che costruisce, scansiona e pusha un'immagine Docker:

yaml
# .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.sarif

Cache 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:

yaml
volumes:
  - .:/app
  - /app/node_modules  # preserves container's node_modules

Gestione 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:

dockerfile
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:

dockerfile
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:

dockerfile
RUN apk add --no-cache tzdata
ENV TZ=America/New_York

Ma 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 in ENV).
  • ENV persiste nell'immagine ed è disponibile a runtime.
dockerfile
# 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 myapp

Monitoraggio in produzione#

Il tuo setup Docker non è completo senza osservabilità. Ecco uno stack di monitoraggio minimale ma efficace:

yaml
# 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:

javascript
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 cachingpackage.json copiato prima del codice sorgente
  • Health check — Istruzione HEALTHCHECK nel Dockerfile
  • Gestione dei segnalidumb-init o --init per la corretta gestione di SIGTERM
  • Nessun segreto nell'immagine — Nessun ENV con 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-size impostato 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à.

Articoli correlati