Zum Inhalt springen
← Alle Beiträge
· 6 Min. Lesezeit· Von

nginx als Reverse Proxy im Container: mehrere Upstreams sauber verdrahten

Ein lauffähiges Minimalbeispiel – nginx per Docker Compose vor drei Backend-Containern, mit upstream-Blöcken, Pfad-Routing und korrekter Header-Weitergabe – plus die drei Fallen mit DNS, Headern und WebSockets.

nginxReverse ProxyDockerDocker ComposeSelf-HostingDevOps

Sobald mehr als ein Dienst auf einem Server läuft, brauchen Sie eine Instanz, die eingehende Requests annimmt und an den richtigen Container weiterreicht. Für diese Aufgabe gibt es die bequemen Kandidaten – zu Caddy und zum Vergleich Caddy vs. Traefik habe ich bereits geschrieben – und es gibt den Klassiker: nginx mit einer von Hand geschriebenen Konfiguration. Genau darin liegt sein Reiz. Sie sehen jede Weiche, jeden Header und jeden Upstream schwarz auf weiß in einer Textdatei, statt sie aus Container-Labels zu rekonstruieren. Dieser Beitrag stellt in einer Viertelstunde einen minimalen, aber vollständigen nginx-Proxy vor drei Backend-Container – und geht die Kernidee einmal durch: upstream-Gruppe, server-Block, proxy_pass, Header.

Was ein Reverse Proxy macht – und die Kernidee

Drei Bausteine tragen die gesamte Konfiguration:

Ein upstream-Block definiert eine benannte Gruppe von Backend-Servern. Er lebt im http-Kontext und wird später nur noch über seinen Namen angesprochen. Stehen mehrere server-Zeilen darin, verteilt nginx die Anfragen laut Doku per gewichtetem Round-Robin – das ist Ihr Lastausgleich, ohne dass Sie irgendetwas aktivieren müssten.

Ein server-Block ist der virtuelle Host: Er entscheidet über listen und server_name, welche Anfragen er annimmt. Darin bestimmen location-Blöcke anhand des Pfads, welcher Upstream zuständig ist, und proxy_pass schickt die Anfrage dorthin.

Der dritte Baustein ist unsichtbar und trotzdem entscheidend: das gemeinsame Docker-Netz. Alle Container hängen in einem benutzerdefinierten Netz, und nur dort löst Dockers eingebauter DNS-Server unter 127.0.0.11 Container- und Service-Namen zu IP-Adressen auf. Im alten Default-Bridge-Netz funktioniert das laut Docker-Doku ausdrücklich nicht – Container erreichen sich dort nur über IPs. Deshalb steht in der Compose-Datei ein eigenes Netz.

Der Stack: compose.yaml

Als Backends nehmen wir dreimal nginxdemos/hello:plain-text. Das Image gibt Hostname, IP und Request-URI als Klartext zurück, sodass man sofort sieht, welcher Container geantwortet hat. In Ihrem echten Setup stehen dort Ihre eigenen Services.

services:
  proxy:
    image: nginx:1.30.4-alpine
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app1
      - app2
      - api
    restart: unless-stopped
    networks: [edge]

  app1:
    image: nginxdemos/hello:plain-text
    restart: unless-stopped
    networks: [edge]

  app2:
    image: nginxdemos/hello:plain-text
    restart: unless-stopped
    networks: [edge]

  api:
    image: nginxdemos/hello:plain-text
    restart: unless-stopped
    networks: [edge]

networks:
  edge:

Das offizielle nginx-Image bindet /etc/nginx/conf.d/*.conf bereits innerhalb des http-Blocks ein. Unsere Datei landet also genau dort, wo map und upstream erlaubt sind – ohne dass wir die Haupt-nginx.conf anfassen müssen.

Die Konfiguration: nginx.conf

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

upstream app_upstream {
    server app1:80;
    server app2:80;
}

upstream api_upstream {
    server api:80;
}

server {
    listen 80 default_server;
    server_name _;

    # Gilt fuer alle location-Bloecke darunter - solange dort
    # kein eigenes proxy_set_header steht (siehe Stolperfalle 2).
    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_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        $connection_upgrade;

    location / {
        proxy_pass http://app_upstream;
    }

    location /api/ {
        proxy_pass http://api_upstream/;
    }
}

Ein Detail zum Schrägstrich: proxy_pass http://api_upstream/; mit abschließendem Slash ersetzt das gematchte Präfix /api/ durch /. Das Backend sieht also /status statt /api/status. Lassen Sie den Slash weg, wird der Pfad unverändert durchgereicht. Das ist die häufigste Ursache für 404er, die nur hinter dem Proxy auftreten.

Starten und prüfen

docker compose up -d
docker compose exec proxy nginx -t
curl -s http://localhost:8080/
curl -s http://localhost:8080/
curl -s http://localhost:8080/api/

Wiederholte Aufrufe auf / verteilen sich auf app1 und app2 – das ist der Round-Robin (bei mehreren Worker-Prozessen nicht zwingend streng abwechselnd). /api/ landet immer beim api-Container.

                        Docker-Netz "edge"
   +--------+      +----------+   location /     +-----------+
   | Client | ---> |  proxy   | ---------------> |  app1:80  |
   |        |      |  nginx   |   upstream       |  app2:80  |
   +--------+      |  :80     |   app_upstream   +-----------+
     :8080         |          |
                   |          |   location /api/ +-----------+
                   |          | ---------------> |  api:80   |
                   +----------+   upstream       +-----------+
                                  api_upstream

Drei typische Stolperfallen

1. nginx löst Container-Namen genau einmal auf. Steht server app1:80; ohne Zusatz im Upstream, ermittelt nginx die IP beim Laden der Konfiguration. Das hat zwei unangenehme Folgen. Erstens: Läuft app1 beim Start des Proxys noch nicht, bricht nginx mit [emerg] host not found in upstream "app1" ab und landet im Restart-Loop – dagegen hilft das depends_on oben. Zweitens: Bekommt ein Backend beim Neuaufbau eine neue IP, zeigt nginx weiter auf die alte und liefert 502, bis Sie neu laden. Seit nginx 1.27.3 ist die saubere Lösung Open Source (vorher nur in der kommerziellen Version): der Parameter resolve zusammen mit einem Shared-Memory-zone und einem Resolver, der auf Dockers DNS zeigt.

upstream app_upstream {
    zone app_upstream 64k;
    resolver 127.0.0.11 valid=10s;
    server app1:80 resolve;
    server app2:80 resolve;
}

valid=10s überschreibt die TTL der DNS-Antwort und begrenzt damit, wie lange nginx eine veraltete Adresse behält.

2. Ohne proxy_set_header bekommt das Backend den falschen Host. nginx setzt per Default proxy_set_header Host $proxy_host; – das Backend sieht also app_upstream als Host-Header, nicht Ihre Domain. Anwendungen mit Name-based Virtual Hosting antworten dann mit der falschen Site, und generierte Links zeigen ins Leere. Genauso wichtig: X-Forwarded-For und X-Forwarded-Proto setzt nginx nie von selbst. Ohne sie sieht Ihre Anwendung als Client-IP nur den Proxy und hält jede Anfrage für unverschlüsseltes HTTP – klassische Ursache für Redirect-Schleifen hinter TLS-Terminierung. Und beachten Sie die Vererbungsregel: proxy_set_header wird nur dann von der übergeordneten Ebene übernommen, wenn auf der aktuellen Ebene kein einziges proxy_set_header steht. Eine einzige Zeile in einem location-Block wirft also den gesamten Satz vom server-Block weg.

3. WebSockets brauchen die Upgrade-Header explizit. Der map-Block oben stammt aus der offiziellen WebSocket-Doku und ist kein Zierrat: Connection wird per Default auf close gesetzt, womit jeder Upgrade-Versuch scheitert. Der Umweg über map ist nötig, weil Connection: upgrade nur gesendet werden darf, wenn der Client es auch angefragt hat. Zwei Hinweise: Seit nginx 1.29.7 ist proxy_http_version standardmäßig 1.1 – auf älteren Versionen müssen Sie die Direktive zusätzlich setzen. Und proxy_read_timeout steht per Default auf 60 Sekunden, was leerlaufende WebSocket-Verbindungen nach einer Minute kappt, sofern die Anwendung keine Pings sendet.

Wie es weitergeht

Der Stack oben spricht bewusst nur HTTP auf Port 8080. Für den öffentlichen Betrieb kommt ein listen 443 ssl; samt ssl_certificate dazu – automatisches HTTPS bleibt bei nginx allerdings Handarbeit: Die ACME-Unterstützung steckt in einem separaten dynamischen Modul (Paket nginx-module-acme), das die aktuellen offiziellen Images zwar mitliefern (nicht die slim-Varianten), das aber erst per load_module aktiviert und konfiguriert werden muss. Wenn genau das Ihr Hauptproblem ist oder sich Ihre Container ständig ändern, sind Caddy oder Traefik die bessere Wahl: Beide holen Zertifikate automatisch und konfigurieren sich per Labels selbst. Bleiben Sie bei nginx, wenn Sie eine feste, versionierbare Konfiguration und maximale Kontrolle über Routing und Header wollen.

Tiefer geht es im ngx_http_upstream_module für Lastverteilung, Gewichte und Health-Parameter, im ngx_http_proxy_module für Timeouts, Buffering und Header sowie in der Beispielkonfiguration des Projekts. Wenn der Proxy steht, ist der nächste Schritt das Patch-Management – dazu gibt es den Beitrag zum Prüfen und Absichern der nginx-Version.

Hinweis zur Veröffentlichung: Dieser Beitrag war für den 23. Juli 2026 geplant. Wegen einer technischen Störung der automatischen Veröffentlichung ist er erst am 11. August 2026 online gegangen. Alle Angaben wurden vor der Veröffentlichung erneut auf Aktualität geprüft.

Hinweis: Die Beiträge dieses Blogs werden unter Einsatz von KI erstellt und vor der Veröffentlichung redaktionell geprüft. Die redaktionelle Verantwortung trägt Emre Yurtbay (siehe Impressum).

Projekt besprechen