nginx als reverse proxy instellen

Gepubliceerd op 15 min leestijd

Van proxy_pass tot WebSocket-upgrade: de volledige handleiding voor nginx als reverse proxy, met de vier headers die voorkomen dat uw applicatie elke bezoeker voor 127.0.0.1 aanziet.

Vrijwel elke moderne applicatie luistert ergens op een hoge poort: Node op 3000, een Docker-container op 8080, Gunicorn op 8000, een Java-applicatieserver op 8443. Dat zet u niet rechtstreeks aan het internet. Daar hoort een reverse proxy voor, en in de praktijk is dat nginx.

De basisconfiguratie daarvoor past in vijf regels. Precies dat is het probleem: die vijf regels lijken te werken, en drie weken later valt op dat elke bezoeker in het applicatielog als 127.0.0.1 staat, dat de rate limit op het inloggen iedereen tegelijk buitensluit en dat de mail voor het herstellen van het wachtwoord een link naar http://127.0.0.1:3000 bevat. Dit artikel behandelt precies die plekken.

Voorwaarde is een geïnstalleerde nginx. Is dat nog niet gebeurd, dan helpt nginx installeren op Debian en Ubuntu.

Wat een reverse proxy technisch werkelijk doet

nginx neemt de verbinding van de bezoeker aan en opent daarop een eigen, tweede verbinding naar de applicatie. Dat is het beslissende punt waaruit alle verdere problemen voortkomen.

Vanuit de applicatie gezien is de client niet de bezoeker, maar nginx. Het bron-IP is 127.0.0.1. Het protocol is http, ook als er buiten HTTPS liep. De Host-header luidt standaard 127.0.0.1:3000 en niet app.example.com. En het is HTTP/1.0 in plaats van HTTP/1.1, waardoor WebSockets zonder aanvullende configuratie principieel stuklopen.

Alles wat de applicatie over de echte bezoeker moet weten, moet nginx haar actief als HTTP-header meegeven. Vanzelf gebeurt dat niet.

De basisconfiguratie, en waar die hoort

De plaats van het bestand verschilt per systeem, en dat wordt geregeld door elkaar gehaald.

Debian en Ubuntu: de configuratie staat in /etc/nginx/sites-available/app.conf en wordt met een symlink in /etc/nginx/sites-enabled/ geactiveerd. Anders vangt het standaardserverblok default alle aanvragen af.

AlmaLinux, Rocky, RHEL en Oracle Linux: daar bestaat sites-available helemaal niet. Het bestand komt rechtstreeks in /etc/nginx/conf.d/app.conf en is daarmee meteen actief.

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

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

Activeren op Debian en 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 vóór elke reload is geen beleefdheid, maar een plicht. Een systemctl reload met een foutieve configuratie laat het oude proces weliswaar doorlopen, maar bij een latere herstart van de server komt nginx dan helemaal niet meer omhoog.

Alle commando's in dit hoofdstuk gaan uit van rootrechten, zet er anders sudo voor. Dat geldt ook voor de zuivere controlecommando's: nginx -t en nginx -T lopen als gewone gebruiker niet stuk op de configuratie, maar op een bestand dat ze helemaal niet mogen schrijven. De melding [emerg] open() "/run/nginx.pid" failed (13: Permission denied) gevolgd door configuration file /etc/nginx/nginx.conf test failed betekent dus niet dat uw configuratie kapot is.

Deze configuratie stuurt het verkeer wel door. Toch is ze kapot, en wel op een manier die zich pas later toont.

Zonder deze vier headers is de applicatie blind

Deze vier regels horen in elk location-blok met 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

Zonder deze regel zet nginx Host: 127.0.0.1:3000. De symptomen: Django antwoordt met Invalid HTTP_HOST header en levert 400. Laravel en WordPress genereren absolute URL's naar 127.0.0.1, zichtbaar in omleidingen en in elke verzonden mail. Een backend dat meerdere domeinen bedient, levert altijd de verkeerde tenant uit.

$host is daarbij de juiste keuze en niet $http_host: $host bevat de hostname zonder poort en valt terug op de server_name als een client helemaal geen Host-header stuurt. $http_host geeft door wat er ook maar binnenkomt, inclusief poortnummer.

X-Real-IP

$remote_addr is het IP-adres waarmee nginx werkelijk praat. Precies één adres, geen komma, geen parsen nodig. Voor applicaties die maar één veld voor het client-IP kennen, is dat de eenvoudigste weg.

X-Forwarded-For

$proxy_add_x_forwarded_for neemt een eventueel al aanwezige X-Forwarded-For en hangt $remote_addr er rechts achter. Bij meerdere proxy's ontstaat zo een keten.

En hier zit een beveiligingslek dat in vrijwel geen enkele handleiding staat: een client kan zelf een X-Forwarded-For meesturen. Staat uw nginx rechtstreeks aan het internet, dan maakt $proxy_add_x_forwarded_for van een vrij verzonnen opgave van de aanvaller en uw echte client-IP één gezamenlijke lijst. Leest uw applicatie vervolgens de eerste vermelding als client-IP, dan gelooft ze elk willekeurig adres. Rate limits, IP-blokkades en geolocatieregels laten zich daarmee omzeilen.

Twee nette gevolgtrekkingen:

  • De keten wordt altijd van rechts gelezen. De laatste vermelding is de enige die uw eigen proxy heeft geschreven.
  • Is nginx de enige instantie voor de applicatie, overschrijf de keten dan liever volledig in plaats van haar aan te vullen:
proxy_set_header X-Forwarded-For $remote_addr;

Daarmee verdwijnt elke vervalste voorgeschiedenis. Alleen als er nog een load balancer of een CDN voor staat die u werkelijk vertrouwt, is $proxy_add_x_forwarded_for juist. In dat geval moet u bovendien de realip-module configureren, zodat nginx zelf al het echte IP kent en niet dat van zijn voorganger logt:

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

De opgave set_real_ip_from is een whitelist. Zonder die opgave doet de module niets, met een te ruime opgave zoals 0.0.0.0/0 is ze een openstaande deur.

X-Forwarded-Proto

De applicatie ziet een zuivere HTTP-verbinding, wat er buiten ook gebeurt. Ontbreekt deze header, dan gebeurt er meestal het volgende: de applicatie merkt "geen HTTPS" en leidt om naar HTTPS, nginx neemt de aanvraag aan, ontsleutelt haar, geeft haar weer als HTTP door, en de applicatie leidt opnieuw om. De browser meldt ERR_TOO_MANY_REDIRECTS. Even vaak voorkomend: cookies met de Secure-flag worden niet gezet, inloggen mislukt zonder foutmelding, en pagina's laden afbeeldingen en scripts via http://, wat de browser als mixed content blokkeert.

Gebruik $scheme en niet de vaste waarde "https". Anders beweert ook het blok op poort 80 dat er versleuteld werd.

Het bewijs dat de headers werkelijk aankomen

Bouw in plaats van te gokken een spiegel. Dit extra serverblok beantwoordt elke aanvraag met de ontvangen headers in platte tekst:

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\nProtocol: $server_protocol\n";
    }
}

Richt proxy_pass bij wijze van test op http://127.0.0.1:9999, herlaad nginx en roep de pagina op. Wat daar staat, is exact wat uw applicatie anders had gekregen. Zijn alle vier de regels gevuld en past het IP bij uw echte aansluiting, dan klopt de configuratie. Verwijder het testblok daarna weer.

De applicatie moet de headers echter ook uitlezen. Express heeft app.set('trust proxy', 1) nodig, Symfony de instelling trusted_proxies, Django USE_X_FORWARDED_HOST en SECURE_PROXY_SSL_HEADER. Zonder die schakelaar negeren de frameworks de headers bewust, juist vanwege het spoofingrisico dat hierboven beschreven staat.

proxy_pass en de slash die alles verandert

De meest voorkomende stille fout van de hele nginx-configuratie. Eén enkel teken bepaalt welk pad er bij de backend aankomt.

ConfiguratieAanvraagBackend ontvangt
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

De regel: zodra er achter host en poort enig pad staat, al is het maar een slash, vervangt nginx het deel van de URL dat op het location-prefix past. Zonder padopgave wordt de complete URL onveranderd doorgegeven.

Het foutbeeld is kenmerkend: de startpagina werkt, maar alles onder een prefix levert 404 op, en in het backendlog staan paden met een dubbel prefix zoals /api/api/users. Een blik in het applicatielog verheldert dat in seconden, gokken bij nginx kost daarentegen uren.

Twee bijzondere gevallen die eigen foutmeldingen opleveren. In een location met een reguliere expressie is een padopgave verboden, nginx breekt anders bij de start af met nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, inside named location, or inside "if" statement. En zodra u een variabele in proxy_pass gebruikt, bijvoorbeeld proxy_pass http://$backend;, lost nginx de naam niet meer bij de start op, maar pas tijdens de uitvoering. Zonder een regel resolver in het serverblok eindigt dat in no resolver defined to resolve ... en een 502.

WebSockets doorsturen

nginx spreekt standaard HTTP/1.0 met de backend. HTTP/1.0 kent het upgrademechanisme niet. Daarom loopt elke WebSocket stuk op de basisconfiguratie, hoe correct al het andere ook is.

Typische symptomen: de browserconsole meldt WebSocket connection to 'wss://app.example.com/ws' failed, vaak met de toevoeging Error during WebSocket handshake: Unexpected response code: 400. Socket.io valt stilzwijgend terug op long polling, de applicatie voelt alleen maar traag aan, en in het accesslog herhalen zich eindeloos regels met /socket.io/?EIO=4&transport=polling.

Eerst het map-blok, dat bepaalt of een verbinding wel een upgrade wil. Dat hoort in de http-context, dus het beste in een eigen bestand /etc/nginx/conf.d/websocket.conf:

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

Belandt dit blok per ongeluk in een server- of location-blok, dan start nginx niet meer: nginx: [emerg] "map" directive is not allowed here.

Daarna in het locationblok:

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

Waarom het map-blok en niet gewoon proxy_set_header Connection "upgrade";? Omdat dan ook heel normale HTTP-aanvragen een upgrade aanvragen. Sommige backends antwoorden daarop met 400, en het hergebruik van keepalive-verbindingen vervalt. Het map-blok stuurt upgrade alleen wanneer de client er werkelijk om heeft gevraagd, en anders close.

De test: in de ontwikkelaarstools van de browser moet de WebSocket-aanvraag de status 101 Switching Protocols tonen. Al het andere, in het bijzonder 200 of 400, betekent dat de upgrade er niet doorheen is gekomen. Op de commandoregel kan het ook zonder 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

Time-outs juist instellen

Breekt een verbinding reproduceerbaar na exact 60 seconden af, dan is dat geen toeval, maar de standaardwaarde van proxy_read_timeout. Belangrijk om te begrijpen: de waarde begrenst niet de totale duur van de aanvraag, maar de pauze tussen twee leesbewerkingen. Een download die tien minuten duurt maar voortdurend data levert, loopt probleemloos door. Een WebSocket waarop 61 seconden lang niets gebeurt, vliegt eruit.

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

proxy_connect_timeout geldt alleen voor het opbouwen van de verbinding en is afgetopt op 75 seconden. Hoger zetten heeft geen zin. Bij een lokale backend is 10 seconden ruim bemeten, en een lage waarde laat u sneller merken dat de service helemaal niet draait.

Voor WebSockets is de betere weg niet proxy_read_timeout 86400s;, maar een heartbeat in de applicatie die elke 30 seconden een ping stuurt. Daarmee blijft de time-out als bescherming tegen hangende verbindingen behouden, in plaats van hem feitelijk uit te schakelen.

Nog twee standaardwaarden die geregeld toeslaan. client_max_body_size staat op 1 MB, elke grotere upload eindigt in 413 Request Entity Too Large en de logregel client intended to send too large body. En bij server-sent events of streamingantwoorden komt er bij de bezoeker lang niets aan, omdat nginx buffert. Dan helpt proxy_buffering off; in het betreffende locationblok, gericht en niet globaal.

HTTPS ervoor zetten

De gemakkelijkste weg is certbot met de nginx-plug-in. Die leest het bestaande serverblok, vult het TLS-deel aan en richt de verlenging in:

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

Deze pakketnamen gelden voor Debian en Ubuntu. Op AlmaLinux, Rocky Linux en Oracle Linux zit certbot niet in de basispakketbronnen, een simpel dnf install -y certbot python3-certbot-nginx eindigt daar met Error: Unable to find a match. EPEL is dwingend nodig:

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

Op Oracle Linux 9 heet het EPEL-pakket oracle-epel-release-el9, eventueel moet de repository eerst met dnf config-manager --enable ol9_developer_EPEL worden geactiveerd.

Voor meerdere subdomeinen loont een blik op Let's Encrypt wildcardcertificaten.

Een versieverschil dat bij het kopiëren van andermans configuraties waarschuwingen oplevert: tot en met nginx 1.24 activeert u HTTP/2 in de listen-regel, vanaf nginx 1.25.1 bestaat daarvoor een eigen directive. Debian 13 levert nginx 1.26.3 en wil de nieuwe notatie, Debian 12 (1.22.1), Ubuntu 24.04 (1.24.0) en Ubuntu 22.04 (1.18.0) de oude. Aan de Red Hat-kant loopt de grens precies zo: AlmaLinux 10 brengt 1.26.3 mee en daarmee de nieuwe vorm, AlmaLinux 9, Rocky Linux 9 en Oracle Linux 9 brengen 1.20.1 mee en hebben de oude nodig.

# nginx vanaf 1.25.1, dus onder meer Debian 13
listen 443 ssl;
http2 on;

# nginx tot en met 1.24, dus Debian 12, Ubuntu 24.04 en 22.04
listen 443 ssl http2;

Gebruikt u de oude vorm op een nieuwe nginx, dan meldt nginx -t: nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead. Dat is slechts een waarschuwing, het werkt dus gewoon door. De nieuwe vorm op een oude nginx is daarentegen een harde startfout: unknown directive "http2".

Tot besluit het belangrijkste punt van de hele oefening: de backend mag niet zelf aan het internet hangen. Een reverse proxy heeft geen nut als http://server-ip:3000 nog steeds rechtstreeks bereikbaar is, want dan kan iedereen de headers zelf zetten zoals het hem uitkomt. Bind de service aan 127.0.0.1.

Bij Docker is dat een bijzonder gemene valkuil: -p 3000:3000 publiceert de poort op alle adressen en zet daarvoor regels klaar die een ufw-firewall eenvoudigweg omzeilen. Juist is -p 127.0.0.1:3000:3000. Details over de installatie staan in Docker installeren op Debian en Ubuntu.

Proxyt u bij uitzondering naar een HTTPS-backend, dan heeft nginx een extra regel nodig, anders stuurt het geen SNI-naam mee en levert de andere kant het verkeerde certificaat:

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

Als het misgaat: de meldingen letterlijk

Het eerste aanspreekpunt is altijd tail -f /var/log/nginx/error.log. De foutpagina van de browser zegt niets, het log zegt alles.

Eén detail vooraf, zodat de eerste regel u niet meteen op het verkeerde been zet: op AlmaLinux, Rocky Linux en Oracle Linux bestaat /var/log/nginx/error.log direct na de installatie nog helemaal niet, dat bestand ontstaat pas bij de eerste start van nginx. tail antwoordt dan met cannot open ... No such file or directory. Op Debian en Ubuntu legt het pakket access.log en error.log al bij de installatie aan. Robuust voor beide werelden:

tail -n 50 /var/log/nginx/error.log 2>/dev/null || journalctl -u nginx -n 50 --no-pager
Melding in het error.logBetekenis en oplossing
connect() failed (111: Connection refused) while connecting to upstreamEr luistert niets op de doelpoort. Controleer de status van de service en ga met ss -ltnp na of poort en adres bij de proxy_pass-regel passen. Ontbreekt ss, dan levert het pakket iproute2 dat op Debian en Ubuntu, en iproute op de Red Hat-familie (daar zonder de 2 in de naam).
connect() failed (113: No route to host)Een firewall tussen nginx en backend blokkeert. Bij containers vaak een verkeerd netwerk.
connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstreamOp AlmaLinux, Rocky, RHEL en Oracle Linux vrijwel altijd SELinux. Aantonen met ausearch -m AVC -ts recent, oplossen met setsebool -P httpd_can_network_connect 1. Op Debian en Ubuntu treedt dit niet op.
upstream timed out (110: Connection timed out) while reading response header from upstreamLevert 504 op. De backend antwoordt te traag. Kijk daar eerst, verhoog pas daarna proxy_read_timeout.
upstream prematurely closed connection while reading response headerLevert 502 op. Het backendproces is tijdens de aanvraag gestorven, vaak door de OOM-killer. Zie swap instellen.
upstream sent too big header while reading response header from upstreamTe grote antwoordheaders, klassiek bij veel of lange cookies. proxy_buffer_size 32k; en proxy_buffers 8 32k; instellen.
no live upstreams while connecting to upstreamBij een upstream-blok zijn alle doelen als uitgevallen gemarkeerd. Stuur het gedrag bij uitval via max_fails en fail_timeout.

Voor de bijzondere gevallen rond 502 bestaat een eigen handleiding: nginx 502 Bad Gateway oplossen. Is uw backend nog helemaal geen netjes startende service, dan loont het om vooraf een systemd-service aan te maken.

Waaraan u ziet dat het werkelijk werkt

Vijf controles die samen zeggingskracht hebben:

  1. nginx -t meldt syntax is ok en test is successful.
  2. nginx -T toont de volledige samengestelde configuratie. Zoek daarin naar proxy_set_header en tel na: alle vier de headers moeten in elk relevant locationblok staan. Eén proxy_set_header in een binnenste blok heft alle geërfde headers van het buitenste blok op, en dat is de meest voorkomende oorzaak van "dat had ik toch juist ingesteld".
  3. In het applicatielog staat het echte bezoekers-IP en niet 127.0.0.1.
  4. Het spiegelserverblok van hierboven levert alle vier de waarden gevuld op, met X-Forwarded-Proto: https bij een aanroep via HTTPS.
  5. Voor WebSockets: status 101 in de ontwikkelaarstools, en de verbinding overleeft meer dan 60 seconden stilte.

Nuttig is bovendien een logformaat dat het doorgegeven IP meeschrijft. Zo ziet u meteen of nginx en applicatie hetzelfde adres bedoelen:

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;

De beide tijdwaarden aan het eind zijn goud waard: $request_time is de totale duur vanuit het gezichtspunt van de bezoeker, $upstream_response_time de duur van het backendantwoord. Liggen beide dicht bij elkaar, dan is de backend traag. Zit er een gat tussen, dan ligt het aan de verbinding naar de client of aan de buffering.

Daarmee staat er een reverse proxy die de applicatie niet alleen bereikbaar maakt, maar haar ook alles meegeeft wat zij over haar bezoekers moet weten. Zet u deze configuratie op een vers systeem op, dan is de checklist voor nieuwe rootservers een goed startpunt voor alles wat daaraan voorafgaat.

Veelgestelde vragen

Waarom ziet mijn applicatie 127.0.0.1 in plaats van het echte bezoekers-IP?
Omdat nginx een eigen, tweede verbinding naar de backend opent. Vanuit de applicatie gezien is nginx daarmee de client. Het echte adres moet actief als header worden meegegeven, via proxy_set_header X-Real-IP $remote_addr en proxy_set_header X-Forwarded-For. Bovendien moet het framework het uitlezen toestaan, bijvoorbeeld met app.set('trust proxy', 1) bij Express of SECURE_PROXY_SSL_HEADER bij Django.
Wat is het verschil tussen X-Real-IP en X-Forwarded-For?
X-Real-IP bevat precies één adres, namelijk dat van de rechtstreeks verbonden client. X-Forwarded-For is een door komma's gescheiden keten waaraan elke proxy zijn voorganger toevoegt. Die keten moet altijd van rechts worden gelezen, want alleen de laatste vermelding komt gegarandeerd van uw eigen proxy. Alles wat verder naar links staat, kan een client vervalst hebben.
Waarom werken mijn WebSockets achter nginx niet?
nginx spreekt standaard HTTP/1.0 met de backend, en HTTP/1.0 kent geen upgrademechanisme. Nodig zijn proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade en proxy_set_header Connection $connection_upgrade, waarbij de variabele $connection_upgrade via een map-blok in de http-context gedefinieerd moet worden. Het is gelukt zodra de browser status 101 Switching Protocols toont.
Waarom breekt mijn verbinding altijd na precies 60 seconden af?
60 seconden is de standaardwaarde van proxy_read_timeout. Die waarde begrenst niet de totale duur, maar de pauze tussen twee leesbewerkingen. Voor langlopende verbindingen kunt u hem verhogen, maar bij WebSockets is een heartbeat in de applicatie beter, zodat de time-out als bescherming behouden blijft.
Wat doet de slash aan het eind van proxy_pass?
Staat er achter host en poort een pad, al is het maar één slash, dan vervangt nginx het deel van de URL dat op het location-prefix past. Bij location /api/ met proxy_pass http://127.0.0.1:3000/ wordt /api/users bij de backend /users. Zonder slash komt /api/users onveranderd aan. Het typische foutbeeld bij een verkeerde keuze is een 404 onder het prefix of een dubbel pad zoals /api/api/users.
Waarom krijg ik op AlmaLinux een 502 met Permission denied?
Op de Red Hat-familie is SELinux standaard actief. Die verbiedt de webservercontext om uitgaande netwerkverbindingen op te bouwen. In het error.log staat dan connect() failed (13: Permission denied) while connecting to upstream. De oplossing is setsebool -P httpd_can_network_connect 1; die wijziging werkt meteen, zonder nginx opnieuw te laden. Op Debian en Ubuntu doet het probleem zich niet voor.

nginx Reverse Proxy proxy_pass WebSocket X-Forwarded-For HTTPS Linux Serverbeheer