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.