← Terug naar tutorials

Nginx 502 Bad Gateway oplossen met Docker (DevOps gids)

nginxdocker502-bad-gatewayreverse-proxydevopscontainer-networkingtroubleshootinglogs

Nginx 502 Bad Gateway oplossen met Docker (DevOps gids)

Een 502 Bad Gateway van Nginx betekent vrijwel altijd: Nginx (reverse proxy) kan geen geldige response krijgen van de upstream (je app, API, PHP-FPM, Node, Gunicorn, uWSGI, enz.). In een Docker-setup komt daar extra complexiteit bij: netwerken, DNS-resolutie binnen Compose, healthchecks, container lifecycle, poortmappings, timeouts, en misconfiguraties die lokaal “toevallig” werken maar in containers falen.

Deze gids is een praktische DevOps-handleiding om systematisch 502’s te debuggen en op te lossen. Je krijgt diepere uitleg, concrete commando’s, en voorbeeldconfiguraties voor Docker + Nginx.


Inhoud

  1. Wat betekent 502 in Nginx precies?
  2. De snelste triage: waar komt de 502 vandaan?
  3. Basis: Nginx als reverse proxy in Docker
  4. Loganalyse: Nginx error log, access log, upstream details
  5. Netwerk en DNS in Docker: service names, networks, resolvers
  6. Veelvoorkomende oorzaken + oplossingen (met echte fixes)
  7. Timeouts, buffering en grote responses
  8. HTTP vs HTTPS upstream, headers en websockets
  9. Healthchecks, depends_on en “app is nog niet klaar”
  10. Debug-tooling in containers: curl, dig, ss, tcpdump
  11. Productie-hardening: retries, observability, best practices
  12. Checklist: 502 in Docker in 5 minuten isoleren

Wat betekent 502 in Nginx precies?

Nginx treedt in veel setups op als reverse proxy: hij ontvangt HTTP(S)-requests van clients en stuurt ze door naar een upstream (bijv. app:3000, php-fpm:9000, gunicorn:8000). Een 502 Bad Gateway betekent dat Nginx wel een upstream probeerde te bereiken, maar:

Belangrijk: 502 is niet hetzelfde als 504.

In Docker zie je 502 vaak door:


De snelste triage: waar komt de 502 vandaan?

Voordat je config gaat aanpassen: bepaal of Nginx zelf faalt of dat je upstream faalt.

1) Check Nginx container status

docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'

Als Nginx steeds herstart, zit je probleem mogelijk in Nginx config (dan is het vaak 500/503 of geen response, maar kan ook 502 veroorzaken via load balancer).

2) Bekijk Nginx logs

docker logs --tail=200 -f nginx

Zoek naar regels zoals:

Deze tekst is vaak direct de oorzaak.

3) Test upstream vanaf Nginx container

Ga in de Nginx container en probeer de upstream te bereiken:

docker exec -it nginx sh
# of bash als beschikbaar

Installeer eventueel tools (afhankelijk van image):

Alpine (nginx:alpine):

apk add --no-cache curl bind-tools iproute2

Debian/Ubuntu:

apt-get update && apt-get install -y curl dnsutils iproute2

Test DNS en HTTP:

getent hosts app
curl -v http://app:3000/health

Als curl faalt, is het upstream-probleem of netwerk/DNS. Als curl werkt maar Nginx geeft 502, is het Nginx-config/protocol/header/timeout.


Basis: Nginx als reverse proxy in Docker

Een veelgebruikte Compose-setup:

Voorbeeld: docker-compose.yml (conceptueel)

Je vroeg om Markdown-only; hieronder staat een voorbeeldblok dat je direct kunt gebruiken. Pas namen/poorten aan.

services:
  nginx:
    image: nginx:1.25-alpine
    container_name: nginx
    ports:
      - "8080:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    depends_on:
      - app
    networks:
      - web

  app:
    image: node:20-alpine
    container_name: app
    working_dir: /app
    volumes:
      - ./app:/app
    command: sh -c "npm install && node server.js"
    expose:
      - "3000"
    networks:
      - web

networks:
  web:
    driver: bridge

Voorbeeld: Nginx config ./nginx/conf.d/default.conf

server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://app:3000;
        proxy_http_version 1.1;

        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;

        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}

Kernprincipe: in Docker Compose is app een DNS-naam binnen het netwerk web. Nginx moet in hetzelfde netwerk zitten om app te resolven en te bereiken.


Loganalyse: Nginx error log, access log, upstream details

Nginx error log live volgen

In veel images logt Nginx naar stdout/stderr, dus:

docker logs -f --tail=200 nginx

Als je een custom config gebruikt met logfiles in de container:

docker exec -it nginx sh -c "tail -n 200 /var/log/nginx/error.log"
docker exec -it nginx sh -c "tail -n 200 /var/log/nginx/access.log"

Extra upstream logging toevoegen

Je kunt meer inzicht krijgen door een custom log_format te gebruiken:

log_format upstreamlog '$remote_addr - $host [$time_local] '
                     '"$request" $status $body_bytes_sent '
                     'rt=$request_time urt=$upstream_response_time '
                     'ua="$http_user_agent" '
                     'upstream="$upstream_addr" ustatus="$upstream_status"';

access_log /var/log/nginx/access.log upstreamlog;

Dit helpt bij vragen als:


Netwerk en DNS in Docker: service names, networks, resolvers

Service name vs container_name

In Compose is de service name (bijv. app) de stabiele DNS-naam. container_name kan werken, maar is minder flexibel en kan conflicts geven. Gebruik bij voorkeur:

Controleer of containers in hetzelfde netwerk zitten

docker network ls
docker network inspect <project>_web

Je wil in de inspect-output beide containers zien onder Containers.

DNS-resolutie testen vanuit Nginx

docker exec -it nginx sh -c "getent hosts app || true"
docker exec -it nginx sh -c "nslookup app || true"

Als getent hosts app niets teruggeeft: Nginx zit niet in hetzelfde netwerk, of de service bestaat niet, of Compose projectnaam klopt niet.

Nginx en DNS caching (belangrijk bij dynamische upstreams)

Nginx resolve’t hostnames vaak eenmalig bij start (afhankelijk van context). Als je upstream IP verandert (container herstart, scaling), kan Nginx blijven wijzen naar een oud IP.

Oplossing: gebruik een resolver en variabele in proxy_pass:

resolver 127.0.0.11 valid=10s ipv6=off;

set $upstream_app app;
proxy_pass http://$upstream_app:3000;

127.0.0.11 is de Docker embedded DNS server binnen containers.


Veelvoorkomende oorzaken + oplossingen (met echte fixes)

Hieronder de meest voorkomende 502-oorzaken in Docker, met diagnose en concrete oplossing.

1) Verkeerde upstream hostnaam (host not found in upstream)

Symptoom in Nginx log:

Diagnose:

Fix:

Commands:

docker exec -it nginx sh -c "getent hosts app"
docker compose ps

2) Verkeerde poort: app luistert intern niet op 3000

Veel mensen verwarren:

Nginx moet altijd naar de containerpoort (rechts) praten, niet naar de hostpoort.

Voorbeeldfout:

Correct:

Diagnose: Check in de app-container welke poorten luisteren:

docker exec -it app sh -c "ss -lntp || netstat -lntp"

Test HTTP direct:

docker exec -it nginx sh -c "curl -v http://app:3000/"

3) App luistert alleen op 127.0.0.1 (localhost) in de container

Dit is een klassieke Docker-valkuil. Als je app bindt op 127.0.0.1, is hij alleen bereikbaar binnen dezelfde container, niet vanaf Nginx.

Diagnose: In de app-container:

docker exec -it app sh -c "ss -lntp | grep 3000 || true"

Als je iets ziet als 127.0.0.1:3000, dan is dit het probleem.

Fix (Node/Express voorbeeld): Zorg dat je luistert op 0.0.0.0:

app.listen(3000, "0.0.0.0");

Fix (Python/uvicorn):

uvicorn main:app --host 0.0.0.0 --port 8000

Fix (Django runserver, alleen dev):

python manage.py runserver 0.0.0.0:8000

4) Upstream crasht of is niet “ready” (race condition bij startup)

Nginx start snel, je app heeft migraties, warming caches, dependency checks. Nginx probeert te proxy’en en krijgt connection refused.

Symptoom:

Diagnose: Bekijk app logs:

docker logs --tail=200 -f app

Fix-opties:

  1. Voeg een health endpoint toe aan je app (/health).
  2. Gebruik healthcheck in Compose en laat Nginx pas verkeer krijgen als app healthy is (of gebruik een entrypoint script).

Compose healthcheck voorbeeld:

services:
  app:
    # ...
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 5s
      timeout: 2s
      retries: 10

Let op: depends_on met health conditions is in moderne Compose varianten beperkt/anders; vaak is een robuuste oplossing:

Nginx retry gedrag verbeteren:

proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_next_upstream_tries 5;
proxy_next_upstream_timeout 10s;

5) Protocol mismatch: Nginx praat HTTP met een HTTPS upstream (of andersom)

Symptomen:

Diagnose: Test met curl vanaf Nginx container:

docker exec -it nginx sh -c "curl -v http://app:3000/"
docker exec -it nginx sh -c "curl -vk https://app:3000/"

Fix:

Bij self-signed certs upstream (intern) kun je tijdelijk:

proxy_ssl_verify off;

Beter is een interne CA of plain HTTP op het interne Docker-netwerk.


6) Nginx config reload/start faalt of verwijst naar niet-bestaande upstream

Soms lijkt het een 502, maar Nginx draait met oude config of start niet goed.

Check config syntax:

docker exec -it nginx nginx -t

Reload:

docker exec -it nginx nginx -s reload

Als nginx -t faalt, los eerst config errors op.


7) PHP-FPM: verkeerd type upstream (FastCGI vs HTTP)

Als je PHP via PHP-FPM draait, gebruik je fastcgi_pass, niet proxy_pass.

Fout (HTTP proxy naar FPM):

location ~ \.php$ {
  proxy_pass http://php-fpm:9000;  # ❌ fout
}

Correct (FastCGI):

location ~ \.php$ {
  include fastcgi_params;
  fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
  fastcgi_pass php-fpm:9000;
}

Diagnose: Nginx error log toont vaak “invalid response” omdat FPM geen HTTP spreekt.


Timeouts, buffering en grote responses

Soms is upstream “gezond”, maar te traag of stuurt grote headers/bodies waardoor Nginx faalt.

Relevante timeouts

Voorbeeld:

location / {
  proxy_pass http://app:3000;
  proxy_connect_timeout 5s;
  proxy_read_timeout 120s;
  proxy_send_timeout 120s;
}

Grote headers: “upstream sent too big header”

Symptoom in error log:

Fix:

proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;

Bij grote cookies/headers (bijv. SSO) kan dit cruciaal zijn.

Streaming / SSE / websockets en buffering

Voor Server-Sent Events of streaming wil je buffering uit:

proxy_buffering off;
proxy_cache off;

HTTP vs HTTPS upstream, headers en websockets

Host en X-Forwarded headers

Veel apps genereren redirects op basis van Host en X-Forwarded-Proto. Zonder die headers kan je app rare redirects doen (bijv. naar http) of verkeerde absolute URLs genereren.

Standaard set:

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;

WebSockets (vaak oorzaak van 502 bij upgrade)

Voor websockets moet je Upgrade en Connection correct doorgeven:

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

server {
  listen 80;

  location /ws/ {
    proxy_pass http://app:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 3600s;
  }
}

Zonder dit kan de upstream de verbinding sluiten of Nginx een 502 geven bij upgrade.


Healthchecks, depends_on en “app is nog niet klaar”

Waarom depends_on niet genoeg is

depends_on zorgt in de basis voor startvolgorde, niet voor “ready to serve”. Je app kan draaien maar nog niet luisteren, of nog migraties uitvoeren.

Praktische aanpak

  1. Health endpoint in de app (/health of /ready).
  2. Healthcheck in Compose.
  3. Nginx configureren met redelijke timeouts + retries.
  4. Eventueel een “wait-for” script in Nginx entrypoint (minder elegant, soms effectief).

Wait-for voorbeeld (simpel):

# in nginx container entrypoint script
until nc -z app 3000; do
  echo "Wachten op app:3000..."
  sleep 1
done
nginx -g 'daemon off;'

Hiervoor heb je nc nodig (apk add --no-cache netcat-openbsd).


Debug-tooling in containers: curl, dig, ss, tcpdump

Als je echt vastzit, ga laag in de stack debuggen.

1) DNS: getent, nslookup, dig

docker exec -it nginx sh -c "getent hosts app"
docker exec -it nginx sh -c "nslookup app 127.0.0.11"

2) TCP connectiviteit: nc of /dev/tcp

docker exec -it nginx sh -c "nc -vz app 3000"

Zonder nc (in bash):

docker exec -it nginx bash -lc "echo > /dev/tcp/app/3000"

3) Luisterende sockets: ss

docker exec -it app sh -c "ss -lntp"
docker exec -it nginx sh -c "ss -lntp"

4) Packet capture (alleen als je weet wat je doet)

Soms wil je zien of SYN/SYN-ACK gebeurt:

docker exec -it nginx sh -c "apk add --no-cache tcpdump"
docker exec -it nginx sh -c "tcpdump -i any -nn host app and port 3000"

Let op: in minimal images is tcpdump niet altijd beschikbaar of gewenst in productie.


Productie-hardening: retries, observability, best practices

1) Gebruik upstream blocks en keepalive

upstream app_upstream {
  server app:3000;
  keepalive 32;
}

server {
  listen 80;

  location / {
    proxy_pass http://app_upstream;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
  }
}

Keepalive kan performance verbeteren en minder connect-fouten geven bij hoge load.

2) Rate limiting en bescherming tegen overload

Als je upstream onder druk omvalt, kan Nginx 502 geven door crashes. Overweeg rate limiting:

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
  location /api/ {
    limit_req zone=api burst=20 nodelay;
    proxy_pass http://app:3000;
  }
}

3) Metrics en tracing (praktisch)

proxy_set_header X-Request-ID $request_id;
add_header X-Request-ID $request_id;

In je app log je X-Request-ID mee om correlatie te krijgen.


Checklist: 502 in Docker in 5 minuten isoleren

Gebruik deze volgorde om snel te pinpointen waar het misgaat:

  1. Logs Nginx

    docker logs --tail=200 nginx

    Zoek: host not found, connection refused, prematurely closed, handshake failed.

  2. Upstream bereikbaar vanaf Nginx?

    docker exec -it nginx sh -c "apk add --no-cache curl >/dev/null 2>&1 || true; curl -v http://app:3000/health"
  3. DNS klopt?

    docker exec -it nginx sh -c "getent hosts app"
  4. Netwerk klopt?

    docker network inspect <project>_web | sed -n '1,200p'
  5. Luistert de app op 0.0.0.0 en juiste poort?

    docker exec -it app sh -c "ss -lntp"
  6. Protocol mismatch uitsluiten

    docker exec -it nginx sh -c "curl -v http://app:3000/ || true; curl -vk https://app:3000/ || true"
  7. Nginx config validatie

    docker exec -it nginx nginx -t
    docker exec -it nginx nginx -s reload

Concreet scenario: van 502 naar opgelost (voorbeeld)

Stel: je Nginx log toont:

connect() failed (111: Connection refused) while connecting to upstream, upstream: "http://app:3000/"

Stap 1: Test vanuit Nginx container

docker exec -it nginx sh -c "curl -v http://app:3000/"

Output faalt met Connection refused.

Stap 2: Kijk of app luistert op 127.0.0.1

docker exec -it app sh -c "ss -lntp | grep 3000 || true"

Je ziet:

LISTEN 0 511 127.0.0.1:3000 ...

Stap 3: Fix app bind address

Pas je app aan naar 0.0.0.0. Herstart:

docker compose restart app

Stap 4: Retest

docker exec -it nginx sh -c "curl -v http://app:3000/"
curl -v http://localhost:8080/

De 502 is weg.


Veelgemaakte misverstanden (kort maar belangrijk)


Afsluiting

Een Nginx 502 in Docker is zelden “mysterieus” als je het terugbrengt tot drie vragen:

  1. Kan Nginx de upstream DNS-resolven?
  2. Kan Nginx een TCP-verbinding maken naar de juiste host:poort?
  3. Spreken Nginx en upstream hetzelfde protocol en voldoet de upstream aan de verwachtingen (headers/timeouts/upgrade)?

Met de commando’s en fixes uit deze gids kun je in de meeste omgevingen binnen minuten de root cause vinden en structureel oplossen.

Als je je Nginx default.conf, je Compose file, en de relevante error logregel(s) deelt (geanonimiseerd), kan ik de diagnose nog specifieker maken en exact aanwijzen welke regel aangepast moet worden.