Configurare nginx come reverse proxy
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: lì 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.
| Configurazione | Richiesta | Il 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.log | Significato e rimedio |
|---|---|
connect() failed (111: Connection refused) while connecting to upstream | Sulla 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 upstream | Sulla 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 upstream | Produce un 504. Il backend risponde troppo lentamente. Guarda prima lì, e solo dopo alza proxy_read_timeout. |
upstream prematurely closed connection while reading response header | Produce 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 upstream | Header 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 upstream | In 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:
nginx -tsegnalasyntax is oketest is successful.nginx -Tmostra la configurazione completa già assemblata. Cercaciproxy_set_headere conta: tutti e quattro gli header devono comparire in ogni blocco location rilevante. Unproxy_set_headerin un blocco interno annulla tutti gli header ereditati dal blocco esterno, ed è la causa più frequente del classico "ma io questo l'avevo impostato".- Nel log dell'applicazione compare l'IP reale del visitatore e non
127.0.0.1. - Il blocco server specchio visto sopra restituisce tutti e quattro i valori valorizzati, con
X-Forwarded-Proto: httpsquando accedi via HTTPS. - 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?
Qual è la differenza fra X-Real-IP e X-Forwarded-For?
Perché i miei WebSocket non funzionano dietro nginx?
Perché la mia connessione cade sempre dopo esattamente 60 secondi?
Che effetto ha la barra finale in proxy_pass?
Perché su AlmaLinux ricevo un 502 con Permission denied?
2026 KernelHost GmbH. Tutti i diritti riservati. Questa guida è protetta dal diritto d'autore. La ripubblicazione su altri siti web, anche parziale o in forma modificata, non è consentita senza il nostro consenso scritto. Le citazioni con indicazione della fonte e un link sono le benvenute.

