Configurare nginx come reverse proxy

Pubblicato il 15 min di lettura

Da proxy_pass all'upgrade WebSocket: la guida completa a nginx come reverse proxy, compresi i quattro header senza i quali la tua applicazione scambia ogni visitatore per 127.0.0.1.

Quasi ogni applicazione moderna resta in ascolto da qualche parte su una porta alta: Node sulla 3000, un container Docker sulla 8080, Gunicorn sulla 8000, un application server Java sulla 8443. Una cosa del genere non la si espone direttamente su internet. Davanti ci va un reverse proxy, e nella pratica quel reverse proxy è nginx.

La configurazione di base sta in cinque righe. Ed è proprio questo il problema: le cinque righe sembrano funzionare, e tre settimane dopo ti accorgi che ogni visitatore compare nel log dell'applicazione come 127.0.0.1, che il rate limit sull'accesso esclude tutti quanti in blocco e che la mail per reimpostare la password contiene un link a http://127.0.0.1:3000. Questo articolo tratta esattamente questi punti.

Il presupposto è avere nginx già installato. Se non l'hai ancora fatto, ti aiuta installare nginx su Debian e Ubuntu.

Che cosa fa davvero un reverse proxy, dal punto di vista tecnico

nginx accetta la connessione del visitatore e apre poi una propria, seconda connessione verso l'applicazione. Questo è il punto decisivo, quello da cui derivano tutti gli altri problemi.

Dal punto di vista dell'applicazione il client non è il visitatore, ma nginx. L'IP del mittente è 127.0.0.1. Il protocollo è http, anche se all'esterno viaggiava HTTPS. L'header Host vale per impostazione predefinita 127.0.0.1:3000 e non app.example.com. E si parla HTTP/1.0 invece di HTTP/1.1, motivo per cui i WebSocket senza configurazione aggiuntiva falliscono sempre.

Tutto quello che l'applicazione deve sapere sul visitatore reale, nginx glielo deve passare attivamente come header HTTP. Da solo non succede.

La configurazione di base, e dove va messa

La posizione del file cambia a seconda del sistema, ed è una cosa che si confonde di continuo.

Debian e Ubuntu: la configurazione sta in /etc/nginx/sites-available/app.conf e si attiva con un symlink in /etc/nginx/sites-enabled/. Altrimenti il blocco server predefinito default intercetta tutte le richieste.

AlmaLinux, Rocky, RHEL e Oracle Linux:sites-available non esiste proprio. Il file va direttamente in /etc/nginx/conf.d/app.conf ed è così subito attivo.

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

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

Attivazione su Debian e Ubuntu:

ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx

nginx -t prima di ogni ricaricamento non è cortesia, è obbligo. Un systemctl reload con una configurazione difettosa lascia sì in esecuzione il vecchio processo, ma un successivo riavvio del server non fa più partire nginx del tutto.

Tutti i comandi di questa sezione presuppongono i permessi di root, altrimenti lavora anteponendo sudo. Vale anche per i semplici comandi di verifica: nginx -t e nginx -T eseguiti come utente normale non falliscono per colpa della configurazione, ma per un file che non hanno il diritto di scrivere. Il messaggio [emerg] open() "/run/nginx.pid" failed (13: Permission denied) seguito da configuration file /etc/nginx/nginx.conf test failed non significa quindi che la tua configurazione sia rotta.

Questa configurazione inoltra le richieste. Resta comunque rotta, e in un modo che si manifesta solo più avanti.

I quattro header senza i quali l'applicazione è cieca

Queste quattro righe vanno in ogni blocco location con proxy_pass:

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

Senza questa riga nginx imposta Host: 127.0.0.1:3000. I sintomi: Django risponde con Invalid HTTP_HOST header e restituisce 400. Laravel e WordPress generano URL assoluti su 127.0.0.1, visibili nei redirect e in ogni mail inviata. Un backend che serve più domini consegna sempre il tenant sbagliato.

$host è la scelta giusta, non $http_host: $host contiene il nome host senza porta e ricade sul server_name se un client non invia alcun header Host. $http_host lascia passare qualunque cosa arrivi, indicazione della porta compresa.

X-Real-IP

$remote_addr è l'IP con cui nginx sta davvero parlando. Esattamente un indirizzo, nessuna virgola, nessun parsing necessario. Per le applicazioni che conoscono un solo campo per l'IP del client, è la strada più semplice.

X-Forwarded-For

$proxy_add_x_forwarded_for prende un eventuale X-Forwarded-For già presente e vi aggiunge $remote_addr a destra. Con più proxy nasce così una catena.

E qui si nasconde una falla di sicurezza che quasi nessuna guida menziona: un client può inviare da sé un X-Forwarded-For. Se il tuo nginx è esposto direttamente su internet, $proxy_add_x_forwarded_for unisce in un'unica lista un valore inventato di sana pianta dall'attaccante e l'IP reale del client. Se ora la tua applicazione legge il primo elemento come IP del client, crede a qualsiasi indirizzo. Rate limit, blocchi per IP e logiche geografiche si aggirano in questo modo.

Due conseguenze pulite:

  • La catena va letta sempre da destra. L'ultimo elemento è l'unico che ha scritto il tuo stesso proxy.
  • Se nginx è l'unica istanza davanti all'applicazione, conviene sovrascrivere completamente la catena invece di allungarla:
proxy_set_header X-Forwarded-For $remote_addr;

Così sparisce ogni cronologia falsificata. Solo se davanti c'è ancora un load balancer oppure una CDN di cui ti fidi davvero, $proxy_add_x_forwarded_for è la scelta corretta. In quel caso va configurato anche il modulo realip, in modo che già nginx stesso conosca l'IP reale e non registri nel log quello del predecessore:

set_real_ip_from 10.0.0.0/8;
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

L'indicazione set_real_ip_from è una whitelist. Senza di essa il modulo non ha alcun effetto, con un valore troppo ampio come 0.0.0.0/0 diventa una porta aperta.

X-Forwarded-Proto

L'applicazione vede una connessione puramente HTTP, qualunque cosa succeda all'esterno. Se questo header manca, di solito accade quanto segue: l'applicazione si accorge che non c'è HTTPS e reindirizza su HTTPS, nginx accetta la richiesta, la decifra, la inoltra di nuovo come HTTP, e l'applicazione reindirizza un'altra volta. Il browser segnala ERR_TOO_MANY_REDIRECTS. Altrettanto frequente: i cookie con il flag Secure non vengono impostati, gli accessi falliscono senza messaggio di errore, e le pagine caricano immagini e script via http://, cosa che il browser blocca come mixed content.

Usa $scheme e non il valore fisso "https". Altrimenti anche il blocco sulla porta 80 sostiene che la connessione fosse cifrata.

La prova che gli header arrivino davvero

Invece di tirare a indovinare, costruisciti uno specchio. Questo blocco server aggiuntivo risponde a ogni richiesta con gli header ricevuti in chiaro:

server {
    listen 127.0.0.1:9999;
    default_type text/plain;

    location / {
        return 200 "Host: $host\nX-Real-IP: $http_x_real_ip\nX-Forwarded-For: $http_x_forwarded_for\nX-Forwarded-Proto: $http_x_forwarded_proto\nProtocollo: $server_protocol\n";
    }
}

Punta proxy_pass per prova su http://127.0.0.1:9999, ricarica nginx e apri la pagina. Quello che leggi lì è esattamente ciò che altrimenti avrebbe ricevuto la tua applicazione. Se tutte e quattro le righe sono valorizzate e l'IP corrisponde alla tua connessione reale, allora la configurazione è a posto. Dopo rimuovi di nuovo il blocco di test.

L'applicazione però deve anche interpretare quegli header. Express ha bisogno di app.set('trust proxy', 1), Symfony dell'impostazione trusted_proxies, Django di USE_X_FORWARDED_HOST e SECURE_PROXY_SSL_HEADER. Senza questo interruttore i framework ignorano gli header di proposito, proprio per il motivo di spoofing descritto sopra.

proxy_pass e la barra che cambia tutto

È l'errore silenzioso più frequente dell'intera configurazione di nginx. Un solo carattere decide il percorso che arriva al backend.

ConfigurazioneRichiestaIl backend riceve
location /api/ { proxy_pass http://127.0.0.1:3000; }/api/users/api/users
location /api/ { proxy_pass http://127.0.0.1:3000/; }/api/users/users
location /api/ { proxy_pass http://127.0.0.1:3000/v2/; }/api/users/v2/users

La regola: non appena dopo host e porta compare un qualsiasi percorso, foss'anche solo una barra, nginx sostituisce la parte di URL che combacia con il prefisso location. Senza indicazione di percorso l'URL completo viene passato invariato.

Il quadro dell'errore è caratteristico: la home funziona, ma tutto quello che sta sotto un prefisso restituisce 404, e nel log del backend compaiono percorsi con prefisso doppio come /api/api/users. Un'occhiata al log dell'applicazione chiarisce la cosa in pochi secondi, tirare a indovinare su nginx invece costa ore.

Due casi particolari che producono messaggi di errore propri. In un location con espressione regolare l'indicazione di un percorso è vietata, altrimenti nginx si interrompe all'avvio con nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, inside named location, or inside "if" statement. E non appena usi una variabile in proxy_pass, per esempio proxy_pass http://$backend;, nginx non risolve più il nome all'avvio, ma a runtime. Senza una riga resolver nel blocco server si finisce con no resolver defined to resolve ... e un 502.

Inoltrare i WebSocket

nginx parla con il backend per impostazione predefinita in HTTP/1.0. HTTP/1.0 non conosce il meccanismo di upgrade. Per questo ogni WebSocket fallisce con la configurazione di base, per quanto corretto sia tutto il resto.

Sintomi tipici: la console del browser segnala WebSocket connection to 'wss://app.example.com/ws' failed, spesso con l'aggiunta Error during WebSocket handshake: Unexpected response code: 400. Socket.io ripiega silenziosamente sul long polling, l'applicazione sembra soltanto lenta, e nell'access log si ripetono all'infinito righe con /socket.io/?EIO=4&transport=polling.

Prima la mappatura, quella che decide se una connessione voglia davvero un upgrade. Questa va nel contesto http, quindi meglio in un file dedicato /etc/nginx/conf.d/websocket.conf:

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

Se questo blocco finisce per sbaglio dentro un blocco server o location, nginx non parte più: nginx: [emerg] "map" directive is not allowed here.

Poi nel blocco location:

location / {
    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_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

Perché la mappatura e non semplicemente proxy_set_header Connection "upgrade";? Perché in quel modo anche le normalissime richieste HTTP chiedono un upgrade. Alcuni backend rispondono con 400, e il riutilizzo keepalive salta. La mappatura invia upgrade solo quando il client ne ha effettivamente chiesto uno, altrimenti close.

Il test: negli strumenti per sviluppatori del browser la richiesta WebSocket deve mostrare lo stato 101 Switching Protocols. Qualunque altra cosa, in particolare 200 o 400, significa che l'upgrade non è passato. Da riga di comando funziona anche senza browser:

curl -sSi -o /dev/null -w '%{http_code}\n' \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  http://127.0.0.1/ws

Impostare correttamente i timeout

Se una connessione cade in modo riproducibile dopo esattamente 60 secondi, non è un caso, è il valore predefinito di proxy_read_timeout. Importante per capirlo bene: il valore non limita la durata complessiva della richiesta, ma la pausa fra due letture. Un download che dura dieci minuti ma consegna dati di continuo passa senza problemi. Un WebSocket su cui per 61 secondi non succede niente viene buttato fuori.

proxy_connect_timeout 10s;
proxy_send_timeout    60s;
proxy_read_timeout    60s;

proxy_connect_timeout vale solo per l'instaurazione della connessione ed è limitato a 75 secondi. Impostarlo più alto non serve a niente. Con un backend locale 10 secondi sono generosi, e un valore basso ti fa notare prima che il servizio non è affatto in esecuzione.

Per i WebSocket la strada migliore non è proxy_read_timeout 86400s;, ma un heartbeat nell'applicazione che invia un ping ogni 30 secondi. Così il timeout resta come protezione contro le connessioni appese, invece di essere di fatto disattivato.

Altri due valori predefiniti che colpiscono regolarmente. client_max_body_size è impostato su 1 MB, e ogni upload più grande finisce in 413 Request Entity Too Large e nella riga di log client intended to send too large body. Inoltre con i Server-Sent Events o con le risposte in streaming al visitatore per un bel pezzo non arriva niente, perché nginx fa buffering. In quel caso aiuta proxy_buffering off; nel blocco location interessato, in modo mirato e non globale.

Mettere HTTPS davanti

La strada più comoda è certbot con il plugin nginx. Legge il blocco server esistente, aggiunge la parte TLS e imposta il rinnovo:

apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d app.example.com

Questi nomi di pacchetto valgono per Debian e Ubuntu. Su AlmaLinux, Rocky Linux e Oracle Linux certbot non sta nei repository di base, e un semplice dnf install -y certbot python3-certbot-nginx finisce lì con Error: Unable to find a match. EPEL è indispensabile:

dnf install -y epel-release
dnf install -y certbot python3-certbot-nginx

Su Oracle Linux 9 il pacchetto EPEL si chiama oracle-epel-release-el9, ed eventualmente il repository va prima abilitato con dnf config-manager --enable ol9_developer_EPEL.

Se hai più sottodomini, vale la pena dare un'occhiata ai certificati wildcard di Let's Encrypt.

Una differenza di versione che genera avvisi quando si copiano configurazioni altrui: fino a nginx 1.24 HTTP/2 si attiva nella riga listen, mentre da nginx 1.25.1 esiste una direttiva dedicata. Debian 13 porta nginx 1.26.3 e vuole la scrittura nuova, Debian 12 (1.22.1), Ubuntu 24.04 (1.24.0) e Ubuntu 22.04 (1.18.0) quella vecchia. Sul fronte Red Hat il confine passa esattamente allo stesso punto: AlmaLinux 10 arriva con 1.26.3 e quindi con la forma nuova, mentre AlmaLinux 9, Rocky Linux 9 e Oracle Linux 9 arrivano con 1.20.1 e hanno bisogno di quella vecchia.

# nginx dalla 1.25.1 in poi, quindi fra gli altri Debian 13
listen 443 ssl;
http2 on;

# nginx fino alla 1.24, quindi Debian 12, Ubuntu 24.04 e 22.04
listen 443 ssl http2;

Se usi la forma vecchia su un nginx recente, nginx -t segnala: nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead. È soltanto un avviso, quindi continua a funzionare. La forma nuova su un nginx vecchio è invece un errore di avvio bloccante: unknown directive "http2".

Per concludere, il punto più importante di tutto l'esercizio: il backend non deve essere esposto direttamente su internet. Un reverse proxy non serve a niente se http://server-ip:3000 resta raggiungibile in modo diretto, perché allora chiunque può impostarsi gli header come gli pare. Fai ascoltare il servizio su 127.0.0.1.

Con Docker questa è una trappola particolarmente insidiosa: -p 3000:3000 pubblica la porta su tutti gli indirizzi e inserisce regole che aggirano semplicemente un firewall ufw. La forma corretta è -p 127.0.0.1:3000:3000. I dettagli sulla configurazione stanno in installare Docker su Debian e Ubuntu.

Se in via eccezionale fai da proxy verso un backend HTTPS, nginx ha bisogno di una riga in più, altrimenti non invia alcun nome SNI e la controparte consegna il certificato sbagliato:

proxy_pass https://backend.intern:8443;
proxy_ssl_server_name on;

Quando qualcosa va storto: i messaggi alla lettera

Il primo posto dove guardare è sempre tail -f /var/log/nginx/error.log. La pagina di errore del browser non dice niente, il log dice tutto.

Una piccolezza in premessa, così la prima riga non ti confonde subito: su AlmaLinux, Rocky Linux e Oracle Linux /var/log/nginx/error.log subito dopo l'installazione non esiste ancora, nasce solo al primo avvio di nginx. tail risponde allora con cannot open ... No such file or directory. Su Debian e Ubuntu il pacchetto crea access.log e error.log già durante l'installazione. Soluzione robusta per entrambi i mondi:

tail -n 50 /var/log/nginx/error.log 2>/dev/null || journalctl -u nginx -n 50 --no-pager
Messaggio nell'error.logSignificato e rimedio
connect() failed (111: Connection refused) while connecting to upstreamSulla porta di destinazione non è in ascolto niente. Verifica lo stato del servizio e controlla con ss -ltnp se porta e indirizzo corrispondono alla riga proxy_pass. Se manca ss, su Debian e Ubuntu lo fornisce il pacchetto iproute2, sulla famiglia Red Hat il pacchetto iproute (lì senza il 2 nel nome).
connect() failed (113: No route to host)Un firewall fra nginx e backend sta bloccando. Con i container spesso si tratta di una rete sbagliata.
connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstreamSulla famiglia Red Hat, quindi AlmaLinux, Rocky, RHEL e Oracle Linux, è quasi sempre SELinux. Lo dimostri con ausearch -m AVC -ts recent, il rimedio è setsebool -P httpd_can_network_connect 1. Su Debian e Ubuntu non si presenta.
upstream timed out (110: Connection timed out) while reading response header from upstreamProduce un 504. Il backend risponde troppo lentamente. Guarda prima lì, e solo dopo alza proxy_read_timeout.
upstream prematurely closed connection while reading response headerProduce un 502. Il processo del backend è morto durante la richiesta, spesso per mano dell'OOM killer. Vedi configurare la swap.
upstream sent too big header while reading response header from upstreamHeader di risposta troppo grandi, un classico con cookie numerosi o molto lunghi. Imposta proxy_buffer_size 32k; e proxy_buffers 8 32k;.
no live upstreams while connecting to upstreamIn un blocco upstream tutte le destinazioni sono state marcate come non disponibili. Regola il comportamento dei controlli di stato con max_fails e fail_timeout.

Per i casi particolari legati al 502 esiste una guida dedicata: risolvere nginx 502 Bad Gateway. Se il tuo backend non è ancora un servizio che parte in modo pulito, prima vale la pena creare un servizio systemd.

Da che cosa capisci che funziona davvero

Cinque verifiche che, prese insieme, sono significative:

  1. nginx -t segnala syntax is ok e test is successful.
  2. nginx -T mostra la configurazione completa già assemblata. Cercaci proxy_set_header e conta: tutti e quattro gli header devono comparire in ogni blocco location rilevante. Un proxy_set_header in un blocco interno annulla tutti gli header ereditati dal blocco esterno, ed è la causa più frequente del classico "ma io questo l'avevo impostato".
  3. Nel log dell'applicazione compare l'IP reale del visitatore e non 127.0.0.1.
  4. Il blocco server specchio visto sopra restituisce tutti e quattro i valori valorizzati, con X-Forwarded-Proto: https quando accedi via HTTPS.
  5. Per i WebSocket: stato 101 negli strumenti per sviluppatori, e la connessione sopravvive a più di 60 secondi di silenzio.

Utile è inoltre un formato di log che riporti anche l'IP inoltrato. Così vedi subito se nginx e applicazione intendono lo stesso indirizzo:

log_format proxied '$remote_addr xff="$http_x_forwarded_for" '
                   'host=$host "$request" $status $body_bytes_sent '
                   'upstream=$upstream_addr rt=$request_time urt=$upstream_response_time';

access_log /var/log/nginx/app.access.log proxied;

I due valori temporali in fondo valgono oro: $request_time è la durata complessiva dal punto di vista del visitatore, $upstream_response_time la durata della risposta del backend. Se i due sono vicini, il backend è lento. Se invece si apre uno scarto, la colpa è della linea verso il client oppure del buffering.

Con questo hai un reverse proxy che non si limita a rendere raggiungibile l'applicazione, ma le passa anche tutto quello che deve sapere sui suoi visitatori. Se imposti questa configurazione su un sistema appena installato, la checklist per i nuovi server root è un buon punto di partenza per tutto quello che viene prima.

Domande frequenti

Perché la mia applicazione vede 127.0.0.1 invece dell'IP reale del visitatore?
Perché nginx apre una propria, seconda connessione verso il backend. Dal punto di vista dell'applicazione il client è quindi nginx. L'indirizzo reale va passato attivamente come header, con proxy_set_header X-Real-IP $remote_addr e proxy_set_header X-Forwarded-For. Inoltre il framework deve consentirne la valutazione, per esempio con app.set('trust proxy', 1) su Express oppure SECURE_PROXY_SSL_HEADER su Django.
Qual è la differenza fra X-Real-IP e X-Forwarded-For?
X-Real-IP contiene esattamente un indirizzo, quello del client collegato direttamente. X-Forwarded-For è invece una catena separata da virgole, a cui ogni proxy aggiunge il proprio predecessore. La catena va letta sempre da destra, perché solo l'ultimo elemento arriva con certezza dal tuo proxy. Tutto quello che sta più a sinistra può essere stato falsificato da un client.
Perché i miei WebSocket non funzionano dietro nginx?
nginx parla con il backend per impostazione predefinita in HTTP/1.0, e HTTP/1.0 non conosce alcun meccanismo di upgrade. Servono proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade e proxy_set_header Connection $connection_upgrade, dove la variabile $connection_upgrade va definita con un blocco map nel contesto http. Funziona quando il browser mostra lo stato 101 Switching Protocols.
Perché la mia connessione cade sempre dopo esattamente 60 secondi?
60 secondi è il valore predefinito di proxy_read_timeout. Il valore non limita la durata complessiva, ma la pausa fra due letture. Per le connessioni di lunga durata lo si può alzare, ma con i WebSocket è meglio un heartbeat nell'applicazione, così il timeout resta come protezione.
Che effetto ha la barra finale in proxy_pass?
Se dopo host e porta compare un percorso, foss'anche una singola barra, nginx sostituisce la parte di URL che combacia con il prefisso location. Con location /api/ e proxy_pass http://127.0.0.1:3000/ la richiesta /api/users arriva al backend come /users. Senza barra /api/users arriva invariata. Il quadro tipico di una scelta sbagliata sono i 404 sotto il prefisso oppure i percorsi doppi come /api/api/users.
Perché su AlmaLinux ricevo un 502 con Permission denied?
Sulla famiglia Red Hat SELinux è attivo per impostazione predefinita e vieta al contesto del web server le connessioni di rete in uscita. Nell'error.log compare allora connect() failed (13: Permission denied) while connecting to upstream. Il rimedio è setsebool -P httpd_can_network_connect 1, e la modifica ha effetto subito, senza ricaricare nginx. Su Debian e Ubuntu il problema non si presenta.

nginx Reverse Proxy proxy_pass WebSocket X-Forwarded-For HTTPS Linux Amministrazione server