Reverse Proxy für mehrere Domains: nginx, Caddy, Traefik

Aktualisiert: 5 Min. Lesezeit Redaktion vserverhost
Kurz gesagt

Ein Reverse Proxy für mehrere Domains lauscht als einziger Dienst auf den Ports 80 und 443 und leitet jede Anfrage anhand des Hostnamens an die passende Anwendung weiter, etwa an localhost:3000 oder einen Docker-Container. Alle Domains bekommen per A- und AAAA-Record dieselbe Server-IP. nginx braucht dafür je einen server-Block, Caddy einen Block pro Domain, Traefik Labels am Container.

Der zweite Webdienst auf einem VServer ist der Moment, an dem man über einen Reverse Proxy nachdenkt. Vorher lief eine Anwendung direkt auf Port 443. Jetzt soll eine zweite dazu, und beide wollen denselben Port. Die Lösung ist ein einzelner Dienst vorne, der die Anfragen nach Hostnamen sortiert.

Wie ein Reverse Proxy mehrere Domains verteilt

Alle Domains zeigen per DNS auf dieselbe IP. Der Proxy nimmt die Verbindung an und schaut auf zwei Dinge: beim TLS-Handshake auf den SNI-Namen, um das richtige Zertifikat zu wählen, und danach auf den Host-Header, um die richtige Anwendung zu finden.

DomainDNSProxy leitet an
example.comA 192.0.2.10127.0.0.1:3000 (Node-App)
blog.example.comA 192.0.2.10127.0.0.1:8080 (Ghost)
beispiel.deA 192.0.2.10/var/www/beispiel.de (statisch)

Die Anwendungen selbst lauschen nur lokal. Das ist auch ein Sicherheitsgewinn, weil sie von außen nicht direkt erreichbar sind. Wie du die Domains auf den Server bekommst, steht unter Domain auf Server umleiten. Ob Subdomains besser per CNAME oder eigenem A-Record eingetragen werden, klärt CNAME oder A-Record.

nginx als Reverse Proxy für mehrere Domains

Pro Domain eine Datei unter /etc/nginx/sites-available/, zum Beispiel example.com:

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

    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;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Die letzten drei Zeilen braucht es nur für Websockets. Für die zweite Domain kopierst du die Datei, änderst server_name und den Port. Dann aktivieren und Zertifikate holen:

ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
ln -s /etc/nginx/sites-available/blog.example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d example.com -d www.example.com
certbot --nginx -d blog.example.com

certbot ergänzt die listen 443 ssl-Zeilen und die Weiterleitung von HTTP auf HTTPS. Warum ein Zertifikat pro Website besser ist als ein großes für alle, erklärt der Artikel zum Let’s-Encrypt-Zertifikat.

Der vergessene Catch-all-Server

Kommt eine Anfrage mit einem Hostnamen, den nginx nicht kennt, etwa direkt an die IP oder von einer fremden Domain, die auf deine IP zeigt, liefert nginx den ersten passenden Server-Block. Das ist meist irgendeine deiner Seiten. Besser:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;
    return 444;
}

ssl_reject_handshake (ab nginx 1.19.4) bricht den TLS-Handshake ab, ohne dass du ein Zertifikat für den Catch-all brauchst. 444 schließt die Verbindung ohne Antwort.

Caddy: mehrere Domains in wenigen Zeilen

Caddy holt die Zertifikate selbst und leitet HTTP automatisch auf HTTPS um. Das ganze Caddyfile für die drei Beispiele:

example.com, www.example.com {
    reverse_proxy 127.0.0.1:3000
}

blog.example.com {
    reverse_proxy 127.0.0.1:8080
}

beispiel.de {
    root * /var/www/beispiel.de
    file_server
}
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

reverse_proxy setzt X-Forwarded-For und X-Forwarded-Proto von sich aus und reicht Websockets durch. Wer nur Webseiten und ein paar Apps betreibt, ist mit Caddy am schnellsten fertig. Weniger Stellschrauben heißt allerdings auch: Für exotische Fälle musst du öfter in die Doku.

Traefik: Domains per Docker-Label

Traefik liest seine Konfiguration aus den Labels der Container. Ein minimales compose.yaml:

services:
  traefik:
    image: traefik:v3
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "[email protected]"
      - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt

  app:
    image: ghcr.io/example/app:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`example.com`) || Host(`www.example.com`)"
      - "traefik.http.routers.app.entrypoints=websecure"
      - "traefik.http.routers.app.tls.certresolver=le"
      - "traefik.http.services.app.loadbalancer.server.port=3000"

Jeder weitere Dienst bekommt dieselben Labels mit eigenem Router-Namen und eigener Host-Regel. Das Volume für acme.json ist Pflicht: Ohne es holt Traefik bei jedem Neustart neue Zertifikate, und nach fünf Duplikaten pro Woche sperrt Let’s Encrypt weitere Ausstellungen für dieselben Namen.

Docker-Ports und die Firewall

Ein Fallstrick, der Leute regelmäßig überrascht: Veröffentlichst du einen Container mit -p 3000:3000, ist Port 3000 von außen erreichbar, auch wenn ufw ihn nicht erlaubt. Docker trägt seine eigenen iptables-Regeln ein, und die greifen vor den Regeln von ufw. Dein Reverse Proxy ist dann nur eine von zwei Türen.

Hinter einem Proxy auf dem Host bindest du Container deshalb nur an localhost:

services:
  app:
    image: ghcr.io/example/app:latest
    ports:
      - "127.0.0.1:3000:3000"

Läuft der Proxy selbst in Docker, wie Traefik im Beispiel oben, brauchst du für die Apps gar keine ports. Beide hängen im selben Docker-Netz, und Traefik spricht den Container direkt über dessen interne Adresse an. Von außen prüfst du das mit nc -zv 192.0.2.10 3000 von einem anderen Rechner. Kommt keine Verbindung zustande, ist es richtig.

Die App hinter dem Proxy: echte IP und HTTPS erkennen

Hinter dem Proxy sieht deine Anwendung jede Anfrage als HTTP-Verbindung von 127.0.0.1. Ohne Anpassung schreibt sie falsche IPs ins Log, erzeugt http://-Links oder hängt beim Login in einer Weiterleitungsschleife. Die Header setzt der Proxy, auswerten muss sie die App, und dafür muss sie dem Proxy ausdrücklich vertrauen:

FrameworkEinstellung
Expressapp.set('trust proxy', 'loopback')
DjangoSECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
LaravelMiddleware TrustProxies mit der Proxy-IP
Railsconfig.assume_ssl = true und vertrauenswürdige Proxies

Vertraue nur der Adresse deines eigenen Proxys. Wer X-Forwarded-For von beliebigen Quellen übernimmt, lässt Clients ihre IP frei erfinden, was Rate-Limits und IP-Sperren aushebelt.

Welcher Proxy für welchen Server

KriteriumnginxCaddyTraefik
Zertifikatecertbot nötigAutomatischAutomatisch
KonfigurationDateien pro SiteEin CaddyfileDocker-Labels
Wildcard per DNS-01Über certbot-PluginEigener Build mit DNS-ModulDNS-Provider im Resolver
StärkeFlexibel, sehr verbreitetKürzeste ConfigDynamische Container

Meine Faustregel: Läuft alles in Docker und kommen Dienste häufig dazu, Traefik. Sonst Caddy, wenn du schnell fertig sein willst, und nginx, wenn du schon nginx-Erfahrung hast oder Spezialfälle wie Caching und Rate-Limiting brauchst. Für dynamische Subdomains brauchst du zusätzlich ein Wildcard-Zertifikat.

Nächster Schritt

Wenn der Proxy steht, ist eine neue Domain nur noch ein DNS-Eintrag und ein Block in der Config. Für das nächste Projekt kannst du verfügbare Namen bei betadomain prüfen, etwa eine .io-Domain für 61,99 € im Jahr. Die Jahrespreise aller Endungen gelten dort auch für jede Verlängerung, und die A-Records legst du im Kundenbereich direkt an.

Schritt für Schritt

  1. DNS für alle Domains setzenFür jede Domain einen A- und AAAA-Record auf die IP des Servers anlegen.
  2. Anwendungen auf lokale Ports legenJede Anwendung auf 127.0.0.1 und einem eigenen Port oder in einem Docker-Netz laufen lassen, nicht direkt auf 80 oder 443.
  3. Proxy-Konfiguration anlegenPro Domain einen server-Block in nginx, einen Block im Caddyfile oder Router-Labels in Traefik anlegen.
  4. Forwarding-Header setzenHost, X-Forwarded-For und X-Forwarded-Proto an die Anwendung durchreichen, bei Websockets zusätzlich Upgrade und Connection.
  5. Zertifikate holenBei nginx mit certbot --nginx pro Domain, bei Caddy und Traefik automatisch beim ersten Aufruf.
  6. Catch-all-Server einrichtenEinen Standard-Server definieren, der unbekannte Hostnamen abweist, damit nicht zufällig eine deiner Seiten ausgeliefert wird.

Domain gefunden? Prüfen und registrieren kannst du sie direkt bei Domain kaufen – Jahrespreis ab 2,99 €.

Häufige Fragen

Brauche ich für jede Domain eine eigene IP-Adresse?

Nein. Seit SNI schickt der Browser den gewünschten Hostnamen schon beim TLS-Handshake mit, sodass ein Server auf einer IP beliebig viele HTTPS-Domains mit eigenen Zertifikaten bedienen kann. Eigene IPs brauchst du nur noch in Sonderfällen.

Welcher Reverse Proxy ist der einfachste?

Caddy, weil er Zertifikate ohne Zusatzkonfiguration holt und erneuert und eine Domain in drei Zeilen eingerichtet ist. nginx ist verbreiteter und flexibler, verlangt aber certbot dazu. Traefik lohnt sich vor allem, wenn ohnehin alles in Docker läuft.

Warum zeigt meine App falsche Links oder http statt https?

Die App sieht hinter dem Proxy nur eine HTTP-Verbindung von 127.0.0.1. Du musst die Header Host, X-Forwarded-For und X-Forwarded-Proto setzen und der App sagen, dass sie dem Proxy vertrauen darf. Wie das geht, hängt vom Framework ab.

Können zwei Reverse Proxies gleichzeitig auf Port 443 laufen?

Nein, nur ein Prozess kann auf einer IP und einem Port lauschen. Wenn nginx und Traefik parallel laufen sollen, muss einer davon auf einem anderen Port lauschen und vom anderen angesprochen werden. Einfacher ist es, sich für einen zu entscheiden.