Servire un'applicazione Node.js con nginx

Node.js permette di creare server HTTP direttamente in JavaScript, ma esporre l'applicazione sulla porta 80 o 443 in produzione non è la soluzione consigliata. nginx, web server ad alte prestazioni e reverse proxy, si interpone tra Internet e l'applicazione Node.js, gestendo connessioni TLS, bilanciamento del carico, file statici e molto altro. In questa guida vedremo come configurare l'intera catena in modo robusto.

Prerequisiti

Si assume di avere un server Linux (Ubuntu 22.04 o compatibile), Node.js installato nella versione LTS corrente, npm, e i privilegi di amministratore per installare nginx e gestire i servizi systemd. Si assume inoltre di possedere un dominio DNS che punta all'indirizzo IP pubblico del server.

Installazione di nginx

Aggiorniamo l'indice dei pacchetti e installiamo nginx:

# Aggiorniamo i pacchetti disponibili
sudo apt update

# Installiamo nginx
sudo apt install -y nginx

# Verifichiamo che il servizio sia attivo
sudo systemctl status nginx

nginx si avvia automaticamente alla fine dell'installazione. La directory di configurazione principale è /etc/nginx; i virtual host (chiamati server block in nginx) vengono definiti in /etc/nginx/sites-available/ e abilitati con un collegamento simbolico in /etc/nginx/sites-enabled/.

Struttura dell'applicazione Node.js

Creiamo una semplice applicazione Express che risponde alle richieste HTTP sulla porta 3000. Questa porta non sarà esposta direttamente a Internet; nginx farà da intermediario.

# Creiamo la directory del progetto
mkdir /var/www/myapp && cd /var/www/myapp

# Inizializziamo il progetto Node.js
npm init -y

# Installiamo Express
npm install express
// File principale dell'applicazione: server.js
const express = require('express');
const app = express();

// Porta su cui ascolta il server Node.js (non esposta direttamente)
const PORT = process.env.PORT || 3000;

// Middleware per il parsing del corpo JSON
app.use(express.json());

// Rotta principale
app.get('/', (req, res) => {
  // Risposta di esempio con JSON
  res.json({ message: 'Ciao dal server Node.js!', timestamp: new Date() });
});

// Rotta di health check usata da nginx e dai monitor
app.get('/health', (req, res) => {
  res.status(200).send('OK');
});

// Avvio del server
app.listen(PORT, '127.0.0.1', () => {
  // Il server ascolta solo sull'interfaccia di loopback
  console.log(`Server in ascolto su 127.0.0.1:${PORT}`);
});

Notare che il server viene avviato su 127.0.0.1 e non su 0.0.0.0: in questo modo l'applicazione è raggiungibile solo localmente, rendendo nginx l'unico punto di ingresso dall'esterno.

Gestire il processo con PM2

In produzione è fondamentale che l'applicazione Node.js si riavvii automaticamente in caso di crash e al riavvio del server. PM2 è il process manager più diffuso per questo scopo.

# Installiamo PM2 globalmente
sudo npm install -g pm2

# Avviamo l'applicazione con PM2
pm2 start /var/www/myapp/server.js --name "myapp"

# Salviamo la lista dei processi per il riavvio automatico
pm2 save

# Generiamo lo script di avvio systemd
pm2 startup systemd -u www-data --hp /var/www

PM2 crea un file di configurazione in ~/.pm2/ e registra un servizio systemd che lo avvia prima che nginx sia accessibile. Per applicazioni più complesse, conviene usare un file di configurazione dedicato:

// File di configurazione PM2: ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'myapp',
      script: './server.js',
      instances: 'max',        // Un processo per ogni core CPU disponibile
      exec_mode: 'cluster',    // Modalità cluster di Node.js
      watch: false,            // Disabilitato in produzione
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
      // Log separati per output e per errori
      out_file: '/var/log/myapp/out.log',
      error_file: '/var/log/myapp/error.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss',
    },
  ],
};
# Avviamo usando il file di configurazione
pm2 start ecosystem.config.js

Configurare nginx come reverse proxy

Un reverse proxy riceve le richieste dal client e le inoltra al server backend (nel nostro caso Node.js), restituendo poi la risposta al client. nginx è estremamente efficiente in questo ruolo grazie alla sua architettura asincrona basata su eventi.

Creiamo il file di configurazione del virtual host:

# /etc/nginx/sites-available/myapp

# Blocco upstream: definisce il gruppo di server backend
upstream node_backend {
    # Indirizzo e porta dell'applicazione Node.js
    server 127.0.0.1:3000;

    # Mantiene connessioni persistenti verso il backend
    keepalive 64;
}

server {
    listen 80;
    listen [::]:80;

    # Sostituire con il proprio dominio
    server_name example.com www.example.com;

    # Dimensione massima del corpo della richiesta (upload)
    client_max_body_size 10M;

    # File di log specifici per questo virtual host
    access_log /var/log/nginx/myapp.access.log;
    error_log  /var/log/nginx/myapp.error.log warn;

    # Serve i file statici direttamente senza coinvolgere Node.js
    location /static/ {
        alias /var/www/myapp/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Tutte le altre richieste vengono inoltrate al backend Node.js
    location / {
        proxy_pass http://node_backend;

        # Intestazioni necessarie affinché Node.js conosca il client reale
        proxy_http_version 1.1;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "upgrade";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeout per evitare connessioni pendenti
        proxy_connect_timeout 60s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;
    }
}
# Abilitiamo il virtual host con un collegamento simbolico
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/

# Rimuoviamo il virtual host predefinito se non più necessario
sudo rm /etc/nginx/sites-enabled/default

# Verifichiamo la sintassi della configurazione
sudo nginx -t

# Ricarichiamo nginx senza interruzioni di servizio
sudo systemctl reload nginx

Abilitare HTTPS con Let's Encrypt

Servire traffico su HTTP non cifrato è inaccettabile in produzione. Let's Encrypt offre certificati TLS gratuiti e rinnovabili automaticamente. Certbot è lo strumento ufficiale per ottenerli e integrarli con nginx.

# Installiamo Certbot e il plugin per nginx
sudo apt install -y certbot python3-certbot-nginx

# Otteniamo e installiamo automaticamente il certificato
sudo certbot --nginx -d example.com -d www.example.com

# Verifichiamo che il rinnovo automatico funzioni
sudo certbot renew --dry-run

Certbot modifica il file di configurazione di nginx aggiungendo le direttive TLS e un redirect automatico da HTTP a HTTPS. Dopo l'operazione il blocco server sarà simile al seguente:

# Configurazione aggiornata da Certbot con TLS abilitato

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    server_name example.com www.example.com;

    # Percorsi dei certificati gestiti da Certbot
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Parametri di sicurezza TLS moderni
    ssl_protocols             TLSv1.2 TLSv1.3;
    ssl_ciphers               HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_cache         shared:SSL:10m;
    ssl_session_timeout       10m;

    # Header di sicurezza aggiuntivi
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options DENY;

    client_max_body_size 10M;

    access_log /var/log/nginx/myapp.access.log;
    error_log  /var/log/nginx/myapp.error.log warn;

    location /static/ {
        alias /var/www/myapp/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location / {
        proxy_pass http://node_backend;

        proxy_http_version 1.1;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "upgrade";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 60s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;
    }
}

# Redirect permanente da HTTP a HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

Leggere l'IP reale del client in Node.js

Poiché le richieste arrivano a Node.js attraverso nginx, l'indirizzo IP del client non è req.socket.remoteAddress ma si trova nell'intestazione X-Forwarded-For. Express può essere configurato per fidarsi del reverse proxy e usare automaticamente quella intestazione:

// server.js - Configurazione del trust del reverse proxy

const express = require('express');
const app = express();

// Indichiamo a Express di fidarsi del primo proxy nella catena
// Necessario per req.ip e req.protocol corretti
app.set('trust proxy', 1);

app.get('/info', (req, res) => {
  res.json({
    // Ora contiene l'IP reale del client, non quello di nginx
    clientIp: req.ip,
    // Restituisce 'https' anche se Node.js riceve HTTP da nginx
    protocol: req.protocol,
  });
});

Bilanciamento del carico con più istanze

Se l'applicazione deve gestire un traffico elevato, si possono avviare più istanze di Node.js su porte diverse e lasciare che nginx distribuisca il carico tra di esse. PM2 in modalità cluster gestisce internamente questo scenario, ma è anche possibile farlo esplicitamente:

# Blocco upstream con tre istanze Node.js su porte diverse
upstream node_cluster {
    # Algoritmo di bilanciamento: least_conn sceglie il server con meno connessioni attive
    least_conn;

    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;

    keepalive 64;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    # ... configurazione TLS omessa per brevità ...

    location / {
        proxy_pass http://node_cluster;

        proxy_http_version 1.1;
        proxy_set_header Upgrade         $http_upgrade;
        proxy_set_header Connection      "upgrade";
        proxy_set_header Host            $host;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Gestione dei WebSocket

Le applicazioni Node.js usano spesso WebSocket per comunicazioni in tempo reale. nginx supporta il proxying di WebSocket, ma richiede due intestazioni specifiche per negoziare correttamente l'upgrade del protocollo:

# Configurazione per il proxying dei WebSocket
location /socket.io/ {
    proxy_pass http://node_backend;

    proxy_http_version 1.1;

    # Queste due intestazioni sono obbligatorie per l'upgrade a WebSocket
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # Timeout esteso: le connessioni WebSocket restano aperte a lungo
    proxy_read_timeout 3600s;
}

Ottimizzazioni delle performance

nginx offre diverse direttive per migliorare le prestazioni. Le seguenti impostazioni si inseriscono nel blocco http di /etc/nginx/nginx.conf:

# /etc/nginx/nginx.conf - Impostazioni globali di performance

http {
    # Usa un numero di worker pari ai core CPU disponibili
    # (impostato solitamente già nel blocco events)

    # Abilita la trasmissione efficiente dei file statici
    sendfile           on;
    tcp_nopush         on;
    tcp_nodelay        on;

    # Durata delle connessioni keep-alive dal client verso nginx
    keepalive_timeout  65;

    # Compressione gzip per le risposte testuali
    gzip              on;
    gzip_vary         on;
    gzip_proxied      any;
    gzip_comp_level   6;
    gzip_types        text/plain text/css application/json
                      application/javascript text/xml
                      application/xml application/xml+rss
                      text/javascript;

    # Dimensione del buffer per la lettura delle intestazioni del client
    client_header_buffer_size    1k;
    large_client_header_buffers  4 8k;

    # Limita la velocità di lettura del corpo della richiesta dal client
    client_body_timeout   12s;
    client_header_timeout 12s;

    # Timeout per l'invio della risposta al client
    send_timeout 10s;
}

Rate limiting

Per proteggere l'applicazione da abusi e attacchi di tipo brute force, nginx permette di limitare il numero di richieste per indirizzo IP nell'unità di tempo:

# Definizione della zona di rate limiting (va nel blocco http)
# Riserva 10 MB di memoria per tracciare gli IP: contiene circa 160.000 voci
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

server {
    # ...

    location /api/ {
        # Applica il limite: fino a 10 req/s con una coda di 20 richieste
        limit_req zone=api_limit burst=20 nodelay;

        # Restituisce 429 (Too Many Requests) invece del predefinito 503
        limit_req_status 429;

        proxy_pass http://node_backend;

        proxy_http_version 1.1;
        proxy_set_header Host            $host;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Monitoraggio e log

Per analizzare il comportamento del sistema è utile configurare un formato di log personalizzato che includa le informazioni trasmesse da nginx al backend:

# Formato di log dettagliato per il debug del proxy
log_format proxy_format '$remote_addr - $remote_user [$time_local] '
                        '"$request" $status $body_bytes_sent '
                        '"$http_referer" "$http_user_agent" '
                        'rt=$request_time ut="$upstream_response_time" '
                        'cs=$upstream_cache_status';

server {
    # ...
    access_log /var/log/nginx/myapp.access.log proxy_format;
}

Dal lato Node.js, è buona pratica usare un logger strutturato come pino che emette JSON, facilitando l'ingestione dei log in sistemi come Elasticsearch o Loki:

// Configurazione del logger pino in server.js
const express = require('express');
const pino    = require('pino');
const pinoHttp = require('pino-http');

// Inizializziamo il logger con output JSON in produzione
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport: process.env.NODE_ENV !== 'production'
    ? { target: 'pino-pretty' }  // Formato leggibile in sviluppo
    : undefined,                  // JSON puro in produzione
});

const app = express();
app.set('trust proxy', 1);

// Middleware per il logging automatico di ogni richiesta HTTP
app.use(pinoHttp({ logger }));

const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  req.log.info('Richiesta alla rotta principale ricevuta');
  res.json({ status: 'ok' });
});

app.listen(PORT, '127.0.0.1', () => {
  logger.info({ port: PORT }, 'Server avviato');
});

Script di deployment

In un flusso di deploy tipico si aggiornano i sorgenti, si reinstallano le dipendenze, si riavvia PM2 con zero downtime e si ricarica nginx:

#!/bin/bash
# Script di deployment: deploy.sh

# Interrompiamo lo script al primo errore
set -e

APP_DIR="/var/www/myapp"

echo "Aggiornamento del codice sorgente..."
cd "$APP_DIR"
git pull origin main

echo "Installazione delle dipendenze di produzione..."
npm ci --omit=dev

echo "Riavvio dell'applicazione con zero downtime..."
pm2 reload ecosystem.config.js --update-env

echo "Verifica della configurazione di nginx..."
sudo nginx -t

echo "Ricaricamento di nginx..."
sudo systemctl reload nginx

echo "Deployment completato con successo."

Verifica del funzionamento

Una volta completata la configurazione, possiamo verificare l'intera catena con alcuni comandi:

# Verifichiamo che Node.js risponda localmente
curl -i http://127.0.0.1:3000/health

# Verifichiamo che nginx inoltri correttamente le richieste
curl -i https://example.com/health

# Controlliamo i log di nginx in tempo reale
sudo tail -f /var/log/nginx/myapp.access.log

# Controlliamo lo stato dei processi PM2
pm2 status

# Monitoriamo CPU e memoria dei processi Node.js
pm2 monit

Conclusioni

La combinazione di Node.js, PM2 e nginx costituisce uno stack maturo e comprovato per applicazioni web in produzione. nginx gestisce con efficienza le connessioni TLS, il rate limiting, i file statici e il bilanciamento del carico, delegando a Node.js esclusivamente la logica applicativa. PM2 garantisce la disponibilita continua del processo Node.js. Seguendo i passaggi descritti in questa guida si ottiene un'infrastruttura sicura, performante e facilmente scalabile orizzontalmente, pronta ad affrontare carichi di traffico reali.