SSD Nodes Learn 🎉 VPS da $5.50/mese
Guide Matt ConnorDi Matt Connor

Configurazione nginx reverse proxy: guida completa

Configura nginx riga per riga su Ubuntu 24.04: proxy_pass, 4 header essenziali, WebSocket, slash finali, upload e verifica con nginx -t.

Cosa fa una configurazione nginx per il reverse proxy

Un reverse proxy nginx riceve le richieste sulle porte 80 e 443 e inoltra ciascuna richiesta a un'applicazione già in ascolto su una porta locale. Quindi restituisce al browser la risposta dell'applicazione. La configurazione è costituita da un unico blocco `server` ed è breve. Quasi tutta la complessità è concentrata in cinque o sei righe che comunicano all'applicazione quale fosse il client reale e quale protocollo abbia usato.

Tutto quanto segue viene configurato da zero su Ubuntu 24.04, usando il pacchetto nginx della distribuzione. Il punto di partenza è un'applicazione che risponde già su `127.0.0.1:3000`. Se non hai ancora scelto un proxy, leggi prima il confronto tra nginx, Caddy e Traefik. Di seguito è mostrata la configurazione nginx, riga per riga.

Esegui queste configurazioni sul tuo server. Verifica ogni modifica con `sudo nginx -t` prima di ricaricare la configurazione e leggi l'output.

Dove nginx conserva la configurazione su Ubuntu

sudo apt update
sudo apt install -y nginx
ls -l /etc/nginx/sites-enabled/

Il file principale è /etc/nginx/nginx.conf. Imposta le opzioni globali all'interno di un blocco http { } e include quindi due directory: /etc/nginx/conf.d/*.conf e /etc/nginx/sites-enabled/*. Su Ubuntu e Debian si crea un file per ogni sito in /etc/nginx/sites-available/ e lo si abilita creando un link simbolico in /etc/nginx/sites-enabled/. Eliminando il link simbolico si disabilita il sito, ma il file rimane disponibile.

Due direttive utilizzate più avanti funzionano solo nel contesto http, mai all'interno di un blocco server: map e upstream. Inseriscile in un file dedicato sotto /etc/nginx/conf.d/, perché questa directory viene inclusa al livello http.

Il pacchetto installa un sito abilitato chiamato default. È contrassegnato come default_server, quindi risponde a ogni richiesta il cui header Host non corrisponde a nessun server_name presente nella configurazione. Finché rimane abilitato, una richiesta che non corrisponde ai tuoi nomi viene indirizzata a questo sito anziché alla tua applicazione. Rimuovi il link simbolico quando il tuo sito funziona correttamente.

sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Il blocco server minimo che fa da proxy per un'applicazione

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Salvalo come /etc/nginx/sites-available/app.example.com, quindi abilitalo e caricalo.

sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
curl -sI -H 'Host: app.example.com' http://127.0.0.1/

listen 80; associa il blocco a IPv4 e listen [::]:80; lo associa a IPv6. Se ometti la seconda riga e una visita risolve il nome DNS (domain name system) del server in un record AAAA, la connessione viene rifiutata, mentre tutti gli utenti IPv4 vedono un sito funzionante. La segnalazione che riceverai sarà: "da me funziona".

server_name viene confrontato con l'header Host inviato dal browser. Puoi elencare più nomi, separati da spazi. Se nessun blocco corrisponde, nginx usa il blocco default_server, motivo per cui era necessario rimuovere il sito fornito con il pacchetto.

location / esegue una corrispondenza per prefisso sul percorso della richiesta, mentre / corrisponde a qualsiasi percorso. proxy_pass è l'indirizzo verso cui nginx apre una connessione. Mantieni l'applicazione in ascolto su 127.0.0.1, in modo che l'unico percorso di accesso passi da nginx. Se l'applicazione viene eseguita in un container, pubblicala come 127.0.0.1:3000:3000 e non come 3000:3000, perché Docker crea regole proprie e pubblica le porte bypassando direttamente ufw, quindi una porta pubblicata senza ulteriori restrizioni è raggiungibile da Internet indipendentemente dalla configurazione del firewall.

La riga curl invia l'header Host corretto dal server stesso, così puoi testare il blocco prima che il DNS punti a una destinazione.

Cosa invia nginx al backend se non si specifica altro

proxy_pass da solo nasconde quattro informazioni all'applicazione.

Per impostazione predefinita, nginx usa HTTP/1.0 per comunicare con il backend e invia Connection: close. Di conseguenza, ogni richiesta apre una nuova connessione upstream e non è possibile alcun upgrade del protocollo.

L'header Host viene riscritto con il valore di proxy_pass, cioè 127.0.0.1:3000. Un'applicazione che costruisce link assoluti in base a Host genera quindi link che nessuno dall'esterno del server può aprire.

La connessione che raggiunge l'applicazione proviene da nginx, quindi l'applicazione rileva come indirizzo client 127.0.0.1. Ogni riga di log e ogni limite di frequenza applicato dall'applicazione registrano quindi il proxy invece del visitatore.

L'applicazione non può sapere che il browser ha usato HTTPS, perché la connessione ricevuta è HTTP in chiaro su un indirizzo di loopback.

Quattro righe risolvono tutto questo.

I 4 header da impostare e le informazioni che rendono disponibili al backend

location / {
    proxy_pass http://127.0.0.1:3000;

    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;
}

Host contiene il nome digitato dal visitatore. $host è il nome ricavato dalla richiesta, senza la porta e con le lettere convertite in minuscolo. Impostatelo per consentire all'applicazione di creare URL assoluti corretti, ad esempio il redirect dopo il login o il link contenuto in un'email per reimpostare la password. Se lo omettete, questi URL puntano a 127.0.0.1:3000 e, dopo il login, il browser viene indirizzato a un indirizzo che rifiuta la connessione. Se l'applicazione deve conoscere anche la porta, perché il servizio è pubblicato sulla porta 8080, usate $http_host, cioè l'header esattamente come è stato inviato dal client.

X-Real-IP contiene un solo valore: $remote_addr, l'indirizzo da cui nginx ha accettato la connessione. Le applicazioni lo leggono per i propri log degli accessi e per il proprio rate limiting.

X-Forwarded-For contiene un elenco. $proxy_add_x_forwarded_for aggiunge $remote_addr al valore che il client aveva già inserito in quell'header. Il risultato è separato da virgole e l'elemento aggiunto da nginx è l'ultimo. Questo dettaglio determina se l'header può essere considerato attendibile: un client può inviare qualunque X-Forwarded-For e un'applicazione che legge il primo elemento può quindi ricevere un indirizzo arbitrario. Quando nginx è il server edge, scrivete invece $remote_addr e ignorate il valore inviato dal client. Quando davanti a nginx si trova una CDN o un altro proxy, usate set_real_ip_from e real_ip_header del modulo realip, così $remote_addr diventa l'indirizzo effettivo del client.

X-Forwarded-Proto contiene http o https. I framework li leggono per decidere se contrassegnare i cookie come Secure e se forzare un redirect verso HTTPS. Se lo omettete su un sito TLS, un'applicazione configurata per forzare HTTPS rileva http, risponde con un redirect verso l'indirizzo HTTPS, riceve la richiesta successiva tramite nginx, rileva ancora http e ripete il redirect. Il browser interrompe il tentativo e mostra ERR_TOO_MANY_REDIRECTS.

Ripetere queste 4 righe in ogni location porta a differenze tra le configurazioni. Inseritele in un unico file e includetelo.

# /etc/nginx/snippets/proxy-headers.conf
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;
location / {
    include snippets/proxy-headers.conf;
    proxy_pass http://127.0.0.1:3000;
}

L'ereditarietà presenta un problema in questo caso. Una location eredita le direttive proxy_set_header dal relativo blocco server solo se non ne definisce alcuna. Se aggiungete una direttiva proxy_set_header nella location, tutti gli header definiti a livello server vengono rimossi da quella location. Mantenete quindi tutti gli header allo stesso livello oppure include lo snippet in ogni location che usa il proxy.

Perché la mia applicazione WebSocket si connette e poi si disconnette?

Perché le impostazioni predefinite impediscono l’upgrade e il timeout di lettura predefinito chiude un tunnel inattivo dopo 60 secondi. Una connessione WebSocket inizia come una richiesta HTTP che contiene Upgrade: websocket e Connection: Upgrade. Questi sono header hop-by-hop: il proxy deve consumarli invece di inoltrarli. Inoltre, HTTP/1.0 non dispone di alcun meccanismo di upgrade. È quindi necessario reimpostare entrambi manualmente.

La direttiva map deve trovarsi nel contesto http, in un file dedicato.

# /etc/nginx/conf.d/websocket.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Poi configurare il blocco location.

location / {
    include snippets/proxy-headers.conf;
    proxy_pass http://127.0.0.1:3000;

    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

La map consente a una singola location di gestire entrambi i tipi di traffico. In una richiesta normale $http_upgrade è vuoto, quindi $connection_upgrade diventa close. In una richiesta di upgrade contiene websocket, quindi l’header inviato al backend diventa Connection: upgrade. Impostare proxy_set_header Connection "upgrade"; in modo statico invia quell’header anche per ogni richiesta di una normale pagina. Alcuni backend rispondono a una richiesta di questo tipo con un errore 400.

proxy_read_timeout è la causa dei messaggi del tipo «la pagina si carica, poi smette di aggiornarsi». Il valore predefinito è 60 secondi. Misura l’intervallo tra due letture dal backend, non la durata della connessione. Se una connessione WebSocket rimane inattiva per 60 secondi, nginx la chiude e la console del browser mostra la chiusura del socket con il codice 1006. Le applicazioni che inviano il proprio heartbeat più spesso di una volta al minuto non rilevano il problema. Le applicazioni che non lo fanno si interrompono dopo un minuto. Il problema emerge prima negli editor collaborativi e nei dashboard; un’istanza self-hosted di n8n dietro HTTPS è un esempio comune.

Perché una barra finale in proxy_pass modifica gli URL?

La regola è una sola frase. Se proxy_pass termina con un URI (uniform resource identifier), anche un semplice /, nginx rimuove la parte del percorso della richiesta che corrispondeva al prefisso location e la sostituisce con quell'URI. Se proxy_pass si ferma all'host e alla porta, il percorso della richiesta viene inoltrato senza modifiche.

location /app/ {
    proxy_pass http://127.0.0.1:3000/;
}

Una richiesta a /app/status arriva al backend come /status.

location /app/ {
    proxy_pass http://127.0.0.1:3000;
}

Una richiesta a /app/status arriva al backend come /app/status.

La forma da usare dipende dall'applicazione. Un'applicazione con un'impostazione base-path o sub-folder richiede la seconda forma, configurando l'impostazione con /app. Un'applicazione che non gestisce i prefissi richiede la prima. La prima forma presenta subito uno svantaggio: l'HTML restituito dall'applicazione contiene ancora percorsi assoluti come /static/main.css, il browser li richiede dalla radice del sito, nessuna location corrisponde e la pagina viene visualizzata senza stile. Nella scheda di rete del browser, queste richieste per le risorse restituiscono 404. La soluzione consiste nell'impostare il base-path dell'applicazione oppure nell'aggiungere una seconda location /static/ che punti allo stesso backend.

Una location basata su espressione regolare non può contenere un URI in proxy_pass. sudo nginx -t rifiuta la configurazione e indica il motivo: "proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" block.

Questa intera classe di problemi scompare quando ogni applicazione riceve un nome proprio, app.example.com, e viene inoltrata da location /. I sottopercorsi sono utili solo quando non è possibile aggiungere record DNS.

Come posso mettere più backend dietro un unico nome?

Con un blocco upstream. Appartiene al contesto http, quindi inseriscilo sopra il blocco server nello stesso file oppure in /etc/nginx/conf.d/.

upstream app_backend {
    least_conn;
    server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

La direttiva location lo richiama con: proxy_pass http://app_backend;.

Il metodo predefinito è round robin. least_conn invia ogni richiesta al backend con il minor numero di connessioni attive; è adatto quando le richieste hanno durata variabile. ip_hash associa l'indirizzo di un client a un unico backend. Serve ip_hash quando l'applicazione mantiene le sessioni nella propria memoria, perché il round robin tra due backend di questo tipo disconnette gli utenti in modo casuale quando le loro richieste raggiungono l'istanza che non li ha mai gestiti. La soluzione migliore consiste nello spostare le sessioni in uno storage condiviso.

max_fails=3 fail_timeout=30s indica che 3 tentativi falliti entro 30 secondi rimuovono quel server per 30 secondi. Quando tutti i server del blocco si trovano in questo stato, i client ricevono 502 e il log degli errori contiene no live upstreams while connecting to upstream.

keepalive 32 mantiene aperte fino a 32 connessioni inattive verso i backend per ogni processo worker, eliminando l'handshake TCP dalla maggior parte delle richieste. Funziona solo con proxy_http_version 1.1 e senza alcun Connection: close verso il backend. Se la stessa location usa anche la mappa WebSocket, modifica il caso vuoto da close a una stringa vuota, in modo che le richieste ordinarie non trasmettano alcun header Connection e la connessione del pool possa essere riutilizzata.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

I nomi all'interno di un blocco upstream vengono risolti all'avvio di nginx. Se il backend è un container che riceve un nuovo indirizzo quando viene riavviato, nginx continua a usare il vecchio indirizzo finché non lo ricarichi. In una rete Docker puoi spostare la risoluzione al momento della richiesta usando il resolver integrato.

resolver 127.0.0.11 valid=10s;
set $backend http://app:3000;
proxy_pass $backend;

Quando i container vengono creati e rimossi abbastanza spesso da costringerti a modificare continuamente nginx, è preferibile usare un proxy che legga le label dei container. Traefik davanti a diverse applicazioni Docker Compose crea le route direttamente dai container.

Perché i caricamenti restituiscono 413 Request Entity Too Large?

client_max_body_size è impostato su 1 megabyte. nginx rifiuta un corpo della richiesta più grande prima che l'applicazione possa riceverne una parte e il log degli errori registra client intended to send too large body. Aumenta questo valore nel blocco server oppure nel blocco location in cui vengono gestiti i caricamenti.

client_max_body_size 512m;

Un valore pari a 0 disabilita completamente il controllo. Anche l'applicazione applica un limite proprio. Se l'errore 413 persiste dopo questa modifica, proviene dal backend. Controlla quindi l'impostazione dei caricamenti dell'applicazione.

Per impostazione predefinita, nginx legge l'intero corpo della richiesta prima di aprire la connessione upstream e scrive prima su disco, in un file temporaneo, i dati di grandi dimensioni. In questo modo protegge l'applicazione dai client lenti, perché il backend riceve il caricamento alla velocità della rete locale. Per caricamenti molto grandi puoi usare lo streaming.

proxy_request_buffering off;

Il backend riceve quindi il corpo man mano che arriva e deve essere in grado di gestirlo. nginx non può inoltre ritentare la richiesta verso un altro upstream, perché il corpo è già stato inoltrato.

client_body_timeout, che per impostazione predefinita è pari a 60 secondi, si applica all'intervallo tra due letture consecutive del corpo, non all'intero caricamento. Un caricamento lento ma costante non supera questo limite. Un caricamento che si interrompe viene terminato.

Buffering delle risposte e impostazione che interrompe l'output in tempo reale

proxy_buffering è abilitato per impostazione predefinita ed è generalmente ciò che serve. nginx legge la risposta dall'applicazione alla velocità con cui l'applicazione la produce, la memorizza e la invia a un client lento seguendo il ritmo del client. Il worker dell'applicazione termina prima, invece di rimanere occupato per l'intera durata del download lento.

Questo interrompe le risposte in streaming. Gli eventi Server-Sent Events e l'output dei log in tempo reale non mostrano nulla al lettore finché il buffer non si riempie. Disabilita il buffering solo in quella location.

proxy_buffering off;

Se controlli l'applicazione, la soluzione migliore consiste nell'inviare l'header X-Accel-Buffering: no soltanto nelle risposte in streaming. nginx legge quell'header per ogni risposta e disabilita il buffering solo per quella risposta, mantenendo il vantaggio per le pagine ordinarie.

Quando il log degli errori contiene upstream sent too big header while reading response header from upstream, gli header della risposta non entravano in un singolo buffer. proxy_buffer_size è impostato per impostazione predefinita su una singola pagina di memoria, di dimensioni pari a 4 o 8 kilobyte a seconda della piattaforma. Cookie lunghi o header di autenticazione di grandi dimensioni possono superare questo limite. Aumenta entrambi i valori.

proxy_buffer_size 16k;
proxy_buffers 8 16k;

Dove va inserito TLS in questa configurazione?

In nginx, davanti a tutto ciò che è riportato sopra. TLS (transport layer security) termina sul proxy e la connessione da nginx all'applicazione resta in HTTP non cifrato sull'indirizzo di loopback, dove nessun altro host della rete può leggerla. L'applicazione rileva che il visitatore ha usato HTTPS tramite X-Forwarded-Proto, il quarto dei quattro header.

Non inserire manualmente i percorsi dei certificati. Punta il record DNS al server, apri il firewall e lascia che Certbot modifichi questo stesso server block: aggiunge la riga listen 443 ssl con i percorsi ssl_certificate e un redirect dalla porta 80. Emettere un certificato Let's Encrypt per nginx con Certbot descrive l'emissione e il timer per il rinnovo.

sudo ufw allow 'Nginx Full'
sudo ufw status

Nginx Full è un profilo applicativo installato dal pacchetto nginx e apre contemporaneamente la porta 80 e la porta 443. La porta 80 deve restare aperta per la challenge HTTP-01 del rinnovo, anche dopo che tutti i visitatori vengono reindirizzati a HTTPS.

Testare la configurazione, quindi ricaricarla

sudo nginx -t
sudo systemctl reload nginx

nginx -t analizza ogni file incluso e indica se il test è riuscito oppure stampa il file e la riga in cui si è arrestato. Leggere questo output prima di ricaricare la configurazione. Un ricaricamento con una configurazione non valida non viene applicato: nginx continua a usare la configurazione precedente, quindi il sito resta attivo mentre la modifica non produce alcun effetto senza segnalarlo. systemctl restart si comporta in modo diverso e peggiore, perché un riavvio arresta prima il server in esecuzione. Se la configurazione contiene errori, nginx resta quindi completamente arrestato. Usare il ricaricamento per impostazione predefinita e riservare il riavvio alle rare modifiche che lo richiedono.

sudo tail -f /var/log/nginx/error.log
sudo ss -lntp | grep -E ':(80|443|3000)'

La riga ss mostra quale processo detiene ciascuna porta. In questo modo è possibile verificare che l'applicazione sia effettivamente in ascolto dove indica proxy_pass.

Gli errori che incontrerai realmente

502 Bad Gateway, con connect() failed (111: Connection refused) while connecting to upstream nel log degli errori. All'indirizzo indicato in proxy_pass non è in ascolto alcun processo. L'applicazione è arrestata, è associata a un'altra porta oppure è associata a un indirizzo interno del container che l'host non può raggiungere.

502 con no live upstreams while connecting to upstream. Tutti i server nel blocco upstream sono attualmente contrassegnati come non disponibili da max_fails. Ripristina i backend. nginx riproverà a utilizzarli alla scadenza di fail_timeout.

504 Gateway Time-out, con upstream timed out (110: Connection timed out) while reading response header from upstream. Il backend ha accettato la connessione, ma non ha inviato dati per proxy_read_timeout secondi. Aumentare il timeout è corretto per un report realmente lento, ma non per un'applicazione bloccata.

Ogni percorso restituisce 404 dall'applicazione. La regola per la slash finale ha riscritto il percorso. Confronta il percorso registrato nei log dell'applicazione con quello richiesto.

Risponde un sito diverso. server_name non corrisponde all'header Host, quindi la richiesta è ricaduta nel blocco default_server.

La pagina viene caricata, poi l'interfaccia si blocca dopo circa un minuto. Questo è il caso WebSocket: manca la gestione di Upgrade oppure proxy_read_timeout è ancora impostato su 60 secondi.

FAQ

Perché nginx restituisce 502 Bad Gateway dopo l'aggiunta di proxy_pass?

nginx non è riuscito ad aprire una connessione all'indirizzo indicato in proxy_pass. Il log degli errori in /var/log/nginx/error.log indica la causa: connect() failed (111: Connection refused) while connecting to upstream significa che non c'è alcun processo in ascolto, mentre no live upstreams significa che tutti i server in un blocco upstream sono stati contrassegnati come non disponibili. Esegui sudo ss -lntp | grep 3000 per vedere quale processo occupa la porta e a quale indirizzo è associato. Un'applicazione associata a un indirizzo interno al container o a una porta diversa da quella specificata produce sempre questo errore.

Perché la mia applicazione si disconnette dopo circa un minuto dietro nginx?

La connessione usa WebSocket e proxy_read_timeout è ancora impostato sul valore predefinito di 60 secondi, che misura l'intervallo tra due letture dal backend. nginx chiude il socket inattivo e la console del browser segnala il codice di chiusura 1006. Imposta proxy_http_version 1.1, inoltra Upgrade e Connection tramite un map su $http_upgrade e aumenta proxy_read_timeout a un valore come 3600s. Senza l'header Upgrade l'upgrade non avviene, quindi l'applicazione passa al polling oppure non mostra aggiornamenti in tempo reale.

La barra finale in proxy_pass è importante?

Sì, perché modifica il percorso ricevuto dal backend. Con location /app/ e proxy_pass http://127.0.0.1:3000/, una richiesta per /app/status arriva al backend come /status, perché qualsiasi URI dopo host e porta sostituisce il prefisso della location corrispondente. Rimuovendo la barra finale, la stessa richiesta arriva come /app/status. La rimozione del prefisso spesso interrompe i collegamenti alle risorse dell'applicazione, che restano assoluti e restituiscono quindi 404 alla radice del sito. Per un'applicazione con un'impostazione base-path è quindi preferibile la forma che conserva il percorso.

Perché il log della mia applicazione registra 127.0.0.1 come indirizzo IP di ogni visitatore?

Perché la connessione ricevuta dall'applicazione proviene effettivamente da nginx sull'indirizzo di loopback. L'indirizzo del visitatore arriva all'applicazione solo tramite un header configurato dall'utente: proxy_set_header X-Real-IP $remote_addr; per un singolo valore e proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; per la catena concatenata. L'applicazione deve inoltre essere configurata per considerare attendibili questi header. Tieni presente che un client può inviare un proprio X-Forwarded-For; quando nginx è il server edge, sovrascrivilo con $remote_addr invece di aggiungerlo alla catena.

È necessario usare TLS sulla connessione tra nginx e l'applicazione?

Non se l'applicazione è eseguita sullo stesso server ed è associata a 127.0.0.1, perché questo traffico non lascia mai la macchina. Termina TLS su nginx, mantieni proxy_pass su HTTP non cifrato tramite loopback e invia X-Forwarded-Proto $scheme, così l'applicazione sa che il visitatore ha usato HTTPS. Se il backend si trova su un host diverso, attraverso una rete che non controlli, quel collegamento richiede una protezione propria: puoi usare HTTPS verso il backend oppure un tunnel privato tra le due macchine.