Vai al contenuto
·14 min di lettura

La configurazione VPS che funziona davvero: Node.js, PM2, Nginx e deploy senza downtime

L'esatta configurazione VPS che uso in produzione -- hardening di Ubuntu, PM2 in cluster mode, Nginx come reverse proxy, SSL e uno script di deploy che non mi ha mai deluso. Niente teoria, solo ciò che funziona.

Condividi:X / TwitterLinkedIn

Questo blog gira su un VPS da $10/mese. Non Vercel, non AWS, non un cluster Kubernetes gestito da un team di sei persone. Una singola macchina Ubuntu con Nginx, PM2 e uno script bash che fa il deploy in meno di 30 secondi.

Ho provato le altre strade. Ho usato Vercel (ottimo finché non ti servono cron job, WebSocket persistenti o semplicemente controllo). Ho usato AWS (ottimo se ti piace passare met giornata nelle policy IAM). Finisco sempre per tornare su un VPS.

Ma ecco il problema: ogni tutorial "deploy su VPS" su internet si ferma al percorso felice. Ti mostrano come installare Node.js ed eseguire node server.js e lo chiamano produzione. Poi il tuo server subisce un attacco brute-force su SSH, il processo muore alle 3 di notte perché nessuno ha configurato un process manager, e il tuo certificato SSL è scaduto tre mesi fa.

Questa è la guida che avrei voluto avere. Tutto qui è testato in battaglia -- questa esatta configurazione serve la pagina che stai leggendo in questo momento.

Inizia con la sicurezza, non con il codice#

Prima ancora di pensare a Node.js, blinda la macchina. Le istanze VPS appena create sono bersagli. Bot automatizzati iniziano a colpire la tua porta SSH entro pochi minuti dal provisioning.

Crea un utente non-root#

bash
adduser deploy
usermod -aG sudo deploy

Configura l'autenticazione con chiave SSH#

Sulla tua macchina locale:

bash
ssh-copy-id deploy@your-server-ip

Poi disabilita completamente l'autenticazione con password:

bash
sudo nano /etc/ssh/sshd_config
bash
PasswordAuthentication no
PermitRootLogin no
bash
sudo systemctl restart sshd

Se salti questo passaggio, vedrai migliaia di tentativi di accesso falliti nei tuoi log di autenticazione entro pochi giorni. Non è paranoia -- un marted qualsiasi su internet pubblico.

Firewall con UFW#

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Fatto. Quattro regole. Passano solo SSH e traffico web.

Fail2Ban#

bash
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Modifica /etc/fail2ban/jail.local:

ini
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Tre tentativi SSH falliti e vieni bannato per un'ora. Ho visto Fail2Ban bloccare centinaia di IP in un singolo giorno. Funziona.

Aggiornamenti di sicurezza automatici#

bash
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

Il tuo server ora installer automaticamente le patch di sicurezza. Una cosa in meno da dimenticare.

Node.js: usa NVM, non apt#

Vedo questo in ogni tutorial: sudo apt install nodejs. Non farlo.

I repository dei pacchetti di Ubuntu distribuiscono versioni antiche di Node.js. Anche il PPA di NodeSource resta indietro. E quando hai bisogno di passare tra Node 20 e Node 22 per progetti diversi, sei bloccato.

NVM risolve il problema:

bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*

Ora verifica:

bash
node -v  # v22.x.x o qualsiasi LTS corrente
npm -v

Il consiglio non ovvio: quando installi pacchetti globali con NVM (come PM2), sono legati a quella versione di Node. Se cambi versione con nvm use, i tuoi globali scompaiono. Imposta il default e mantienilo sul server:

bash
nvm alias default 22

Questo mi ha fregato esattamente una volta. Una volta è stata sufficiente.

PM2: il process manager che si guadagna il suo posto#

PM2 è la differenza tra "deployato" e "pronto per la produzione". Gestisce il process management, il clustering, la rotazione dei log, il riavvio automatico in caso di crash e gli script di startup. Gratis.

Installazione e configurazione#

bash
npm install -g pm2

La configurazione ecosystem#

Non avviare le app con flag da CLI. Usa un file ecosystem.config.js. tracciato da version control, riproducibile e auto-documentante.

javascript
// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "akousa",
      script: "node_modules/.bin/next",
      args: "start -p 3002",
      cwd: "/var/www/akousa.net",
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "500M",
      env: {
        NODE_ENV: "production",
        PORT: 3002,
      },
      // Shutdown graceful
      kill_timeout: 5000,
      listen_timeout: 10000,
      wait_ready: false,
      // Logging
      log_date_format: "YYYY-MM-DD HH:mm:ss Z",
      error_file: "/var/log/pm2/akousa-error.log",
      out_file: "/var/log/pm2/akousa-out.log",
      merge_logs: true,
      // Riavvio automatico in caso di errore
      autorestart: true,
      max_restarts: 10,
      min_uptime: "10s",
      // Non usare watch in produzione
      watch: false,
    },
  ],
};

Spieghiamo le scelte importanti:

instances: 2 invece di "max": Su un piccolo VPS con 1-2 core, "max" sembra intelligente ma generer processi che si contendono le risorse durante i build. Due istanze ti danno riavvii senza downtime lasciando margine. Su una macchina con 4+ core, certo, usa "max".

exec_mode: "cluster": Questo è ciò che abilita i riavvii senza downtime. Senza la modalità cluster, pm2 reload è solo un restart mascherato. Con la modalità cluster, PM2 riavvia le istanze una alla volta -- la tua app non va mai completamente offline.

max_memory_restart: "500M": La tua app Next.js ha un memory leak? PM2 la riavvier prima che faccia OOM-kill sul tuo server. Questo mi ha salvato da allarmi alle 2 di notte più di una volta.

kill_timeout: 5000: D alla tua app 5 secondi per completare le richieste in corso prima che PM2 la termini forzatamente. Il valore predefinito (1600ms) è troppo aggressivo per app con connessioni al database.

watch: false: Ho visto persone lasciare watch: true in produzione. PM2 poi riavvia l'app ogni volta che un file di log cambia. La tua app entra in un loop di riavvio. Non farlo.

Script di startup#

Fai sopravvivere PM2 ai riavvii:

bash
pm2 startup systemd
# Copia ed esegui il comando che viene mostrato
pm2 save

Questo genera un servizio systemd. Dopo un riavvio del server, la tua app torna automaticamente. Testalo -- riavvia il server e verifica. Non dare nulla per scontato.

Rotazione dei log#

I log alla fine mangeranno il tuo disco. Installa il modulo di rotazione:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true

50MB massimo per file, mantieni 7 file ruotati, comprimi i vecchi. Senza questo, ho visto /var/log riempire un disco da 25GB in tre settimane su un'app con traffico moderato.

Nginx: il reverse proxy che fa più di quanto pensi#

"Perché non esporre Node.js direttamente sulla porta 80?"

Perché Nginx gestisce cose su cui Node.js non dovrebbe sprecare cicli: terminazione SSL, servizio di file statici, compressione gzip, buffering delle richieste, limiti di connessione e gestione elegante dei client lenti. scritto in C e costruito appositamente per questo.

Installazione#

bash
sudo apt install nginx -y

La configurazione#

nginx
# /etc/nginx/sites-available/akousa.net
 
upstream node_app {
    server 127.0.0.1:3002;
    keepalive 64;
}
 
server {
    listen 80;
    listen [::]:80;
    server_name akousa.net www.akousa.net;
 
    # Reindirizza tutto l'HTTP a HTTPS
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name akousa.net www.akousa.net;
 
    # SSL (gestito da Certbot -- queste righe vengono aggiunte automaticamente)
    ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 
    # Header di sicurezza
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
 
    # Compressione gzip
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml
        application/wasm;
 
    # Impostazioni proxy
    location / {
        proxy_pass http://node_app;
        proxy_http_version 1.1;
 
        # Header
        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;
 
        # Supporto WebSocket (se ti dovesse mai servire)
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
 
        # Timeout -- generosi ma non infiniti
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
 
        # Buffering -- lascia che Nginx gestisca i client lenti
        proxy_buffering on;
        proxy_buffer_size 16k;
        proxy_buffers 4 32k;
        proxy_busy_buffers_size 64k;
    }
 
    # Asset statici di Next.js -- lascia che Nginx li serva direttamente
    location /_next/static/ {
        alias /var/www/akousa.net/.next/static/;
        expires 365d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }
 
    # File statici pubblici
    location /static/ {
        alias /var/www/akousa.net/public/static/;
        expires 30d;
        access_log off;
    }
 
    # Blocca l'accesso ai file dot
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Attivala:

bash
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Esegui sempre nginx -t prima di ricaricare. Una volta ho pushato una configurazione rotta e ho mandato già il sito perché ho saltato il controllo della sintassi. I cinque caratteri nginx -t mi avrebbero risparmiato trenta minuti di debugging nel panico.

Cose che la maggior parte dei tutorial non copre in questa configurazione:

Blocco upstream con keepalive 64: Nginx riutilizza le connessioni verso il tuo backend Node.js invece di aprire una nuova connessione TCP per ogni richiesta. Questo conta sotto carico.

proxy_buffering on: Nginx legge l'intera risposta da Node.js in memoria, poi la invia al client alla velocità che il client riesce a gestire. Senza questo, un client lento su una connessione 3G tiene occupato il tuo worker Node.js.

Servire _next/static/ direttamente: Questi sono asset con hash, immutabili. Lascia che Nginx li serva dal disco con un header di cache di 365 giorni. I tuoi processi Node.js non dovrebbero sprecare tempo su questo.

SSL in cinque minuti#

Let's Encrypt ha risolto l'SSL. Se stai ancora pagando per i certificati nel 2026, smetti.

bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.net

Certbot ti chieder la tua email, accetter i ToS e modificher automaticamente la tua configurazione Nginx per includere le direttive SSL. Tutto qui.

Verifica il rinnovo automatico#

Certbot installa un timer systemd che controlla due volte al giorno e rinnova i certificati entro 30 giorni dalla scadenza:

bash
sudo systemctl list-timers | grep certbot

Testa che il rinnovo funzioni:

bash
sudo certbot renew --dry-run

Se il dry run passa, non dovrai più pensare all'SSL. Se fallisce, di solito è perché la porta 80 è bloccata (controlla le regole UFW) o Nginx non è in esecuzione.

Una cosa che mi ha fregato: se configuri Nginx prima di eseguire Certbot, assicurati che il tuo server block sia in ascolto sulla porta 80 senza il redirect HTTPS prima. Certbot ha bisogno di raggiungere la porta 80 per la sfida HTTP-01. Dopo che Certbot ha completato con successo, poi aggiungi il redirect.

Lo script di deploy#

Questo è lo script che viene eseguito ogni volta che pusho in produzione. Nessuna piattaforma CI/CD, nessuna GitHub Actions. Solo SSH e bash.

bash
#!/bin/bash
# deploy.sh -- deployment con quasi zero downtime
 
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
 
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
 
log "=== Deploy iniziato ==="
 
cd "$APP_DIR"
 
# Scarica il codice pi recente
log "Scaricamento ultime modifiche..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
 
# Installa le dipendenze
log "Installazione dipendenze..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
 
# Build
log "Build dell'applicazione..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
 
if [ $? -ne 0 ]; then
    log "ERRORE: Build fallito. Deploy interrotto."
    exit 1
fi
 
# Ricarica PM2 (zero-downtime in modalit cluster)
log "Ricaricamento PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
 
# Health check con retry
log "Esecuzione health check..."
for i in $(seq 1 $MAX_RETRIES); do
    HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
    if [ "$HTTP_CODE" = "200" ]; then
        log "Health check superato (HTTP $HTTP_CODE)"
        log "=== Deploy completato con successo ==="
        exit 0
    fi
    log "Tentativo health check $i/$MAX_RETRIES (HTTP $HTTP_CODE). Riprovo tra ${RETRY_INTERVAL}s..."
    sleep $RETRY_INTERVAL
done
 
log "ERRORE: Health check fallito dopo $MAX_RETRIES tentativi"
log "Rollback allo stato PM2 precedente..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1

Rendilo eseguibile:

bash
chmod +x deploy.sh

Fai il deploy dalla tua macchina locale:

bash
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"

Decisioni chiave in questo script:

set -euo pipefail: Lo script termina immediatamente a qualsiasi errore. Senza questo, un npm install fallito continua silenziosamente al passaggio di build, e ottieni un errore criptico che ti fa perdere 20 minuti di debug.

rm -rf .next prima del build: Next.js ha una cache di build che occasionalmente produce output stantio. Mi è capitato una volta -- una pagina mostrava contenuti vecchi nonostante il codice sorgente fosse aggiornato. Eliminare la directory di build aggiunge forse 15 secondi al build ma garantisce output fresco.

pm2 reload invece di pm2 restart: Questa è la parte zero-downtime. In modalità cluster, reload esegue un riavvio progressivo -- avvia nuove istanze con il codice aggiornato, aspetta che siano pronte, poi spegne elegantemente le vecchie. In nessun momento ci sono zero istanze in esecuzione.

Health check con retry: Next.js impiega qualche secondo per scaldarsi dopo il riavvio. Lo script aspetta fino a 30 secondi (10 tentativi x 3 secondi), controllando se l'app risponde con HTTP 200. Se non lo fa, qualcosa non va e devi saperlo immediatamente -- non scoprirlo da un utente.

Rollback in caso di errore: Se l'health check fallisce dopo tutti i tentativi, lo script riavvia PM2 (che carica l'ultimo stato salvato). Non è un rollback perfetto, ma è meglio che lasciare il server in uno stato rotto.

Quando le cose si rompono alle 2 di notte#

Ecco cosa ho effettivamente debuggato su questa esatta configurazione:

"Il sito è down"#

Primi comandi da eseguire:

bash
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log

Nove volte su dieci, pm2 logs ti dice immediatamente cosa è successo. Una variabile d'ambiente mancante, una connessione al database fallita, o una promise rejection non gestita.

"La memoria continua a crescere"#

bash
pm2 monit

Questo ti d una dashboard live di CPU e memoria per processo. Se la memoria sale costantemente senza stabilizzarsi, hai un leak. L'impostazione max_memory_restart nella tua configurazione ecosystem è la tua rete di sicurezza -- PM2 riavvier il processo prima che faccia cadere il server.

Per un'indagine più approfondita:

bash
pm2 describe akousa

Questo mostra uptime, conteggio riavvii e snapshot della memoria. Se vedi 47 riavvii nelle ultime 24 ore, quello è il tuo indizio.

"Il certificato SSL è scaduto"#

bash
sudo certbot certificates

Elenca tutti i certificati con le relative date di scadenza. Se il rinnovo automatico è fallito:

bash
sudo certbot renew --force-renewal
sudo systemctl reload nginx

"Lo spazio su disco è pieno"#

bash
df -h
du -sh /var/log/*
pm2 flush

pm2 flush cancella immediatamente tutti i file di log di PM2. Se non hai configurato la rotazione dei log (te l'avevo detto), questo è il momento in cui ne senti il dolore.

Il comando che eseguo ogni mattina#

bash
ssh deploy@akousa.net "pm2 status && df -h / && uptime"

Tre cose in una riga: i miei processi stanno girando, il disco sta bene, il server è sovraccarico. Richiede due secondi. Individua i problemi prima degli utenti.

Quello che la maggior parte delle guide non ti dice#

Il passaggio di build è la tua vulnerabilit più grande. Su un VPS da 1GB di RAM, npm run build per un'app Next.js può consumare 800MB+ di memoria. Se PM2 sta eseguendo la tua app in due istanze durante il build, andrai in OOM. Soluzioni: usa un file di swap (almeno 2GB), oppure ferma l'app durante i build e accetta qualche secondo di downtime. Io uso lo swap.

bash
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

--legacy-peer-deps nel tuo comando di installazione è un code smell, non una soluzione. Lo uso perché alcuni pacchetti nel mio albero delle dipendenze non hanno aggiornato i range delle peer dependency. Ogni pochi mesi provo a rimuoverlo. Un giorno funzioner. Fino ad allora, shippo.

Testa il tuo script di deploy da zero. Clona il tuo repository su un server fresco ed esegui ogni passaggio manualmente. Il numero di problemi "funziona sulla mia macchina" che si nascondono negli script di deploy è imbarazzante. Ne ho trovati tre nel mio quando l'ho fatto -- pacchetti globali mancanti, permessi dei file sbagliati e un percorso che esisteva solo a causa di un setup manuale precedente.

Metti l'IP del tuo server nella configurazione SSH. Smetti di digitare indirizzi IP:

bash
# ~/.ssh/config
Host akousa
    HostName 69.62.66.94
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Ora ssh akousa è tutto ciò che serve. Le piccole cose si accumulano.

La checklist completa#

Prima di considerarlo finito:

  • Utente non-root con accesso sudo
  • Autenticazione solo con chiave SSH, password disabilitata
  • UFW abilitato con solo le porte necessarie aperte
  • Fail2Ban a protezione di SSH
  • Aggiornamenti di sicurezza automatici abilitati
  • Node.js installato tramite NVM
  • PM2 che esegue la tua app in modalità cluster
  • Script di startup PM2 configurato (sopravvive al riavvio)
  • Rotazione dei log PM2 installata
  • Nginx come reverse proxy con header corretti
  • SSL tramite Let's Encrypt con rinnovo automatico
  • Script di deploy con health check
  • File di swap configurato (per margine durante il build)
  • Testato: riavvia il server e verifica che tutto torni su

L'ultimo punto è quello che la gente salta. Non essere quella persona. Riavvia il server, aspetta 60 secondi e controlla se la tua app è online. Se non lo , i tuoi script di startup sono mal configurati e lo scoprirai nel momento peggiore.

Questo è "enterprise-grade"?#

No. E questo è il punto.

Questa configurazione serve questo blog in modo affidabile per meno di $10/mese. Si deploya in 30 secondi con un singolo comando. Capisco ogni singolo pezzo. Quando qualcosa si rompe, so esattamente dove guardare.

Potrei usare Docker? Certo. Potrei usare Kubernetes? Tecnicamente. Potrei configurare una pipeline CI/CD completa con ambienti di staging e canary deployment? Assolutamente.

Ma ho imparato che la migliore infrastruttura è quella che capisci davvero, che puoi debuggare alle 2 di notte e che non costa più di quanto il progetto guadagni. Per un sito personale, un MVP SaaS o una piccola startup -- questa è quella configurazione.

Shippa prima. Scala quando serve. E sempre, sempre, testa il tuo script di deploy su un server fresco.