Anleitungen

Kostenloses SSL-Zertifikat mit Let's Encrypt: Nginx-Reverse-Proxy mit HTTPS einrichten

Ein kostenloses SSL-Zertifikat von Let's Encrypt holen: App hinter Nginx legen, mit certbot HTTPS einrichten und WebSockets korrekt durchreichen.

Veröffentlicht ~10 Min. Lesezeit

Kurz gesagt: Lass deine App auf 127.0.0.1:3000 lauschen, gib Nginx einen Server-Block für die Domain, der proxy_pass http://127.0.0.1:3000 macht und die Forwarding-Header durchreicht, und führe dann sudo certbot --nginx -d app.example.com aus. certbot holt ein kostenloses Let's-Encrypt-Zertifikat, fügt HTTPS und die Weiterleitung von HTTP auf HTTPS zum Server-Block hinzu und erneuert das Zertifikat danach automatisch.

Zwei Dinge müssen vorher stehen:

  • DNS: ein A-Eintrag (und ein AAAA-Eintrag, falls der Server IPv6 hat) für app.example.com, der auf den Server zeigt. Let's Encrypt prüft die Domain über das Internet, sie muss sich also zuerst auflösen lassen.
  • Port 80 und 443 offen: sudo ufw allow 'Nginx Full' unter Ubuntu und Debian mit ufw. Port 80 bleibt auch nach erfolgreichem HTTPS offen, weil die Zertifikatsprüfung und die Weiterleitung ihn brauchen.

Wie installiere ich Nginx und certbot?

Ubuntu · Debian
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
sudo ufw allow 'Nginx Full'          # falls ufw aktiv ist
Rocky Linux · CentOS Stream
sudo dnf install -y epel-release      # certbot kommt aus EPEL
sudo dnf install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
sudo setsebool -P httpd_can_network_connect 1   # SELinux: Nginx darf sich mit deiner App verbinden

Die letzte Zeile ist wichtig. Ohne sie verhindert SELinux, dass Nginx sich mit dem Port der App verbindet, und jede Anfrage endet mit einem 502 und (13: Permission denied) while connecting to upstream im Error-Log.

Wie sieht ein minimaler Reverse-Proxy-Server-Block aus?

Zuerst eine kleine gemeinsame Datei, die sich jede proxyte Seite teilen kann. Sie setzt $connection_upgrade, das die WebSocket-Zeilen unten brauchen:

/etc/nginx/conf.d/connection-upgrade.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Dann die Seite selbst. Nur Domain und Port ändern sich von App zu App:

/etc/nginx/sites-available/app.example.com
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    # größter Upload, den Nginx annimmt (Standard: 1 MB)
    client_max_body_size 50m;

    location / {
        # die App, nur von diesem Server aus erreichbar
        proxy_pass http://127.0.0.1:3000;

        # der App sagen, was der Client wirklich wollte
        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;

        # WebSockets
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}
aktivieren und testen
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
curl -I http://app.example.com        # sollte die Header deiner App zeigen

Auf Rocky Linux und CentOS Stream gibt es kein sites-available. Lege denselben Server-Block in /etc/nginx/conf.d/app.example.com.conf und überspringe den Symlink. Führe vor jedem Reload nginx -t aus: Es nennt Datei und Zeile jedes Fehlers, und das laufende Nginx bedient weiter, bis die Konfiguration gültig ist.

Welche Header sollte ich an die App weitergeben?

Hinter einem Reverse-Proxy erreicht jede Anfrage die App von 127.0.0.1 aus über reines HTTP. Die vier Header stellen die ursprünglichen Fakten wieder her:

  • Host ist die Domain, die der Besucher wollte. Ohne ihn sieht die App 127.0.0.1:3000 und baut Links und Weiterleitungen damit.
  • X-Real-IP und X-Forwarded-For tragen die IP-Adresse des Besuchers, damit Logs, Rate-Limits und Geo-Lookups den Besucher statt den Proxy sehen.
  • X-Forwarded-Proto sagt, dass der Besucher HTTPS genutzt hat. Apps, die das nicht wissen, bauen http://-URLs, senden eine eigene Weiterleitung auf HTTPS und landen in einer Schleife, oder verweigern sichere Cookies.

Die App muss diesen Headern vertrauen dürfen – und zwar nur, wenn sie vom Proxy kommen, denn jeder kann sie senden:

dem Proxy vertrauen
// Express
app.set("trust proxy", "loopback");

# Flask / Werkzeug
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

# Django settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# uvicorn vertraut diesen Headern von 127.0.0.1 standardmäßig

Genau deshalb sollte die App auch auf 127.0.0.1 lauschen, nicht auf 0.0.0.0. Ist der App-Port von außen erreichbar, kann jeder den Proxy umgehen und die Header fälschen.

Wie proxye ich WebSockets?

Ein WebSocket beginnt als HTTP-Anfrage mit den Headern Upgrade: websocket und Connection: upgrade. Nginx reicht diese Hop-by-Hop-Header nicht von selbst weiter und spricht standardmäßig HTTP/1.0 mit dem Upstream, das kein Upgrade kann. Die drei Zeilen im Server-Block oben (proxy_http_version 1.1 plus die Header Upgrade und Connection) beheben beides. Dashboards wie n8n, Grafana und Home Assistant sowie alles, was auf Socket.IO aufbaut, brauchen das; ohne diese Zeilen lädt die Seite, bleibt aber bei „connecting“ hängen oder verliert ihre Live-Updates.

Wie bekomme und erneuere ich das Zertifikat?

  1. 1Zertifikat anfordern
    certbot
    sudo certbot --nginx -d app.example.com --redirect

    certbot fragt beim ersten Mal nach einer E-Mail-Adresse und der Zustimmung zu den Bedingungen, beweist Let's Encrypt gegenüber über Port 80, dass du die Domain kontrollierst, und bearbeitet den passenden Server-Block.

  2. 2Was sich geändert hat

    Danach sieht die Datei etwa so aus. certbot markiert seine eigenen Zeilen:

    nach certbot (gekürzt)
    server {
        server_name app.example.com;
        client_max_body_size 50m;
        location / { ... unverändert ... }
    
        listen [::]:443 ssl; # managed by Certbot
        listen 443 ssl; # managed by Certbot
        ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; # managed by Certbot
        ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; # managed by Certbot
        include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
    }
    
    server {
        if ($host = app.example.com) {
            return 301 https://$host$request_uri;
        } # managed by Certbot
        listen 80;
        listen [::]:80;
        server_name app.example.com;
        return 404; # managed by Certbot
    }
  3. 3Die Erneuerung testen
    Erneuerung
    sudo certbot renew --dry-run      # simuliert eine Erneuerung, ändert nichts
    sudo certbot certificates         # Namen und Ablaufdaten
    systemctl list-timers | grep certbot
    
    # Rocky / CentOS Stream (EPEL): der Timer ist nicht von selbst aktiv
    sudo systemctl enable --now certbot-renew.timer

Let's-Encrypt-Zertifikate sind 90 Tage gültig. certbots Timer läuft zweimal täglich, erneuert ein Zertifikat, sobald es innerhalb von 30 Tagen vor dem Ablauf liegt, und lädt danach Nginx neu. Verlass dich nicht auf Erinnerungsmails: Let's Encrypt verschickt seit 2025 keine Ablaufbenachrichtigungen mehr.

Wie betreibe ich mehrere Apps auf einem VPS?

Gib jeder App ihre eigene Subdomain, ihren eigenen Port auf 127.0.0.1 und ihren eigenen Server-Block. Nginx wählt den Block über server_name. Um die Header-Zeilen nicht zu wiederholen, lagere sie in ein Snippet aus:

/etc/nginx/snippets/proxy.conf
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 $connection_upgrade;
zwei Apps, zwei Subdomains
server {
    listen 80;
    server_name app.example.com;
    location / { proxy_pass http://127.0.0.1:3000; include snippets/proxy.conf; }
}

server {
    listen 80;
    server_name n8n.example.com;
    location / { proxy_pass http://127.0.0.1:5678; include snippets/proxy.conf; }
}

# dann ein certbot-Lauf pro Namen, oder beide zusammen:
sudo certbot --nginx -d app.example.com -d n8n.example.com --redirect

Wie proxye ich einen Docker-Container?

Veröffentliche den Port des Containers nur auf 127.0.0.1, und richte proxy_pass wie gewohnt darauf:

Docker · Compose
docker run -d --name app -p 127.0.0.1:3000:3000 myimage

# docker-compose.yml
services:
  app:
    image: myimage
    ports:
      - "127.0.0.1:3000:3000"

Warum schlagen Uploads mit 413 Request Entity Too Large fehl?

Nginx akzeptiert standardmäßig Request-Bodys bis 1 MB und beantwortet alles Größere mit 413, bevor die App davon erfährt. Setze client_max_body_size im Server-Block (wie im Beispiel oben) oder in einem einzelnen location-Block, dann nginx -t und neu laden. Hat die App ein eigenes Upload-Limit, hebe das ebenfalls an.

Warum bekomme ich 502 oder 504 direkt nach der Einrichtung?

  • 502 Bad Gateway: Nginx erreicht die App nicht. Prüfe, ob sie läuft und dort lauscht, wohin proxy_pass zeigt: curl -I http://127.0.0.1:3000 auf dem Server. Ein klassischer Fall ist eine App, die an localhost bindet und auf der IPv6-Adresse ::1 landet; binde sie ausdrücklich an 127.0.0.1. Auf Rocky prüfe den SELinux-Boolean von oben.
  • 504 Gateway Timeout: die App hat die Anfrage angenommen, aber nicht innerhalb von 60 Sekunden geantwortet.

Die erste Zeile von sudo tail /var/log/nginx/error.log nach einer gescheiterten Anfrage sagt meist, welcher der beiden Fälle es ist.

Was ändert sich hinter Cloudflare?

  • Nutze den SSL-Modus Full (strict). Cloudflare verbindet sich dann über HTTPS mit deinem Server und prüft das Zertifikat, und ein Let's-Encrypt-Zertifikat besteht diese Prüfung. Flexible verbindet sich über reines HTTP und ergibt zusammen mit certbots Weiterleitung ERR_TOO_MANY_REDIRECTS.
  • Hol das erste Zertifikat mit dem Eintrag auf „DNS only“ (graue Wolke), dann schalte den Proxy ein. Erneuerungen funktionieren danach durch den Proxy hindurch. Alternativ die DNS-Challenge mit dem Plugin python3-certbot-dns-cloudflare und einem API-Token nutzen, das braucht keinen offenen Port 80 und kann Wildcard-Zertifikate ausstellen.
  • Die echte IP des Besuchers wiederherstellen. Hinter Cloudflare ist $remote_addr eine Cloudflare-Adresse. Das realip-Modul ersetzt sie durch die echte Adresse:
/etc/nginx/conf.d/cloudflare-realip.conf
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... eine Zeile für jeden Bereich von https://www.cloudflare.com/ips/
real_ip_header CF-Connecting-IP;

Nginx oder lieber Caddy?

Brauchst du nur HTTPS vor ein paar Apps, ist Caddy weniger Arbeit: Es holt und erneuert Zertifikate selbst, leitet HTTP auf HTTPS weiter und reicht Forwarding-Header und WebSockets ohne zusätzliche Zeilen durch. Die ganze Konfiguration für das Beispiel oben sind drei Zeilen:

/etc/caddy/Caddyfile
app.example.com {
    reverse_proxy 127.0.0.1:3000
}

Nginx ist die bessere Wahl, wenn es schon auf dem Server läuft, wenn du die feinere Kontrolle über Caching, Buffering und Rate-Limits brauchst, oder wenn dir die deutlich größere Dokumentation wichtiger ist. Caddy ist in Debian 12 und neuer sowie in Ubuntu 24.04 paketiert (apt install caddy).

Wo gehören Rate-Limits hin?

Netzwerkseitiger DDoS-Schutz und Rate-Limits auf Anwendungsebene erledigen unterschiedliche Aufgaben. Bei VMHeaven filtert der dauerhaft aktive DDoS-Schutz Fluten, bevor sie den Server erreichen, aber er kann nicht wissen, dass 50 POST-Anfragen pro Minute an dein Login-Formular ein Angriff sind. Das ist die Aufgabe des Proxys:

Login-Versuche pro IP begrenzen
# /etc/nginx/conf.d/ratelimit.conf (http-Kontext)
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
limit_req_status 429;

# im Server-Block
location = /login {
    limit_req zone=login burst=5 nodelay;
    proxy_pass http://127.0.0.1:3000;
    include snippets/proxy.conf;
}

Hinter Cloudflare richte zuerst den realip-Block ein, sonst teilen sich alle Besucher dieselben wenigen Cloudflare-Adressen und erreichen das Limit gemeinsam. Alles in dieser Anleitung läuft auf jedem KVM-VPS mit Root-Zugang, auf den Ubuntu-, Debian- und Rocky-Linux-Vorlagen gleichermaßen, und ein frischer Ubuntu VPS ist der kürzeste Weg dorthin.

Ob das Zertifikat am Ende tatsächlich ausgeliefert wird, zeigt der SSL-Checker (engl.): Kette, Ablaufdatum und TLS-Version auf Port 443 einer beliebigen Domain.

Häufige Fragen

Ist ein SSL-Zertifikat von Let's Encrypt wirklich kostenlos?

Ja, komplett. Let's Encrypt ist eine gemeinnützige Zertifizierungsstelle und stellt domain-validierte Zertifikate kostenlos aus; certbot holt und erneuert sie automatisch. Es gibt kein Limit an Zertifikaten, nur Ratenlimits pro Domain, die ein normaler Server nie erreicht.

Brauche ich trotzdem ein kostenpflichtiges Zertifikat, wenn meine Seite hinter Cloudflare liegt?

Nein, aber ein gültiges Zertifikat auf deinem Server brauchst du trotzdem für den Modus Full (strict), den du verwenden solltest. Ein kostenloses Let's-Encrypt-Zertifikat von certbot reicht dafür genauso aus wie ein Cloudflare-Origin-CA-Zertifikat.

Wie läuft die automatische Erneuerung mit certbot ab?

certbot installiert einen systemd-Timer, der zweimal täglich läuft und jedes Zertifikat erneuert, das innerhalb von 30 Tagen abläuft, und lädt danach Nginx neu. Mit sudo certbot renew --dry-run testest du das, ohne wirklich etwas zu ändern. Let's Encrypt verschickt keine Erinnerungsmails mehr, verlass dich also auf den Timer.