Zum Inhalt springen
myvpsguide

Reverse-Proxy mit Caddy oder nginx: eine Tür statt drei

Ein Port nach außen, TLS an einer Stelle: vollständige Konfigurationen für Caddy und nginx samt WebSockets, echter Client-IP und HSTS.

veröffentlicht 20.05.2026
2 min lesezeit
fortgeschritten

Sobald der zweite Dienst auf einer Maschine läuft, stellt sich die Frage, wie beide unter einem Namen und mit gültigem Zertifikat erreichbar werden. Die Antwort ist ein Reverse-Proxy: ein Prozess, der Port 80 und 443 hält und Anfragen anhand des Hostnamens intern weiterreicht.

Anfragen aus dem Internet treffen auf den Reverse-Proxy, der TLS beendet und je nach Hostname an einen von drei lokal gebundenen Diensten weiterleitet.
TLS endet am Proxy. Die Dienste dahinter binden auf 127.0.0.1 und sind von außen gar nicht erst erreichbar.

Der eigentliche Gewinn ist nicht die Bequemlichkeit, sondern der zweite Punkt: Dienste binden lokal. Ein vergessener Debug-Port auf 127.0.0.1:9229 ist dann kein offener Debug-Port.

Caddy oder nginx?#

Caddynginx
TLS-Zertifikateautomatisch, nichts zu tunüber certbot oder acme.sh
Konfigurationsumfang für den Standardfall~5 Zeilen~30 Zeilen
Verbreitung, Beispiele im Netzwächstsehr groß
Feinsteuerung (Caching, Rate Limits, Streams)gut, aber begrenztsehr weitgehend
Speicherbedarfetwas mehrsehr sparsam

Caddy, wenn das Ziel „mehrere Dienste sauber nach außen bringen“ ist. nginx, wenn du ohnehin nginx kannst, sehr spezielle Regeln brauchst oder eine bestehende Konfiguration weiterführst.

Beide Wege stehen unten vollständig.

Vorbereitung: DNS und Ports#

# A- und AAAA-Record müssen auf den Server zeigen, bevor ein Zertifikat kommt
dig +short app.example.com A
dig +short app.example.com AAAA

Firewall: nur 80 und 443 nach außen. Port 80 wird für die Zertifikatsprüfung und die Weiterleitung auf HTTPS gebraucht, nicht für den eigentlichen Betrieb.

sudo ufw allow 80,443/tcp comment 'Web'

Caddy#

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

Die komplette Konfiguration für drei Dienste:

# /etc/caddy/Caddyfile

{
    email [email protected]     # für Let's Encrypt-Benachrichtigungen
}

# Gemeinsame Kopfzeilen, einmal definiert
(sicherheit) {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
}

app.example.com {
    import sicherheit
    reverse_proxy 127.0.0.1:3000
    encode zstd gzip
    log {
        output file /var/log/caddy/app.log {
            roll_size 20mb
            roll_keep 5
        }
    }
}

wiki.example.com {
    import sicherheit
    reverse_proxy 127.0.0.1:8080
}

status.example.com {
    import sicherheit
    # Uptime Kuma braucht WebSockets — Caddy leitet sie ohne Zutun durch
    reverse_proxy 127.0.0.1:3001
    # Nur aus dem eigenen Netz erreichbar
    @extern not remote_ip 10.99.0.0/24
    respond @extern 403
}
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -f

Das war es. Zertifikate holt Caddy beim ersten Aufruf selbst und erneuert sie ohne weiteres Zutun.

nginx#

sudo apt install -y nginx certbot python3-certbot-nginx
# /etc/nginx/sites-available/app.example.com

# Gemeinsame Proxy-Einstellungen — einmal schreiben, überall einbinden
# (nach /etc/nginx/snippets/proxy.conf auslagern)
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    # Alles auf HTTPS, außer der ACME-Prüfung
    location /.well-known/acme-challenge/ { root /var/www/html; }
    location / { return 301 https://$host$request_uri; }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_stapling on;
    ssl_stapling_verify on;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    server_tokens off;

    client_max_body_size 512m;      # Uploads: der Wert, der am häufigsten fehlt

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

        # Die echte Client-Adresse weitergeben, sonst sieht die Anwendung
        # nur 127.0.0.1 — und jede IP-basierte Sperre trifft den Proxy.
        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: ohne diese zwei Zeilen bricht jede Live-Verbindung
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_read_timeout 300s;
    }
}
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d app.example.com

Certbot legt einen systemd-Timer für die Erneuerung an. Prüfen, dass er wirklich läuft:

systemctl list-timers certbot.timer
sudo certbot renew --dry-run
X-Forwarded-For ist Vertrauenssache

Die Anwendung dahinter muss wissen, dass sie hinter einem Proxy steht, sonst hält sie den Header für Benutzereingabe und dann kann jeder seine IP-Adresse frei behaupten. In den meisten Frameworks heißt die Einstellung „trusted proxies“ oder „trust proxy“ und braucht die Adresse des Proxys, nicht ein pauschales „an“.

Die drei Fehler, die am häufigsten vorkommen#

1. Der Dienst hört auf 0.0.0.0. Dann ist er trotz Proxy direkt erreichbar und zwar ohne TLS und ohne die Sperren des Proxys.

sudo ss -tulpn | grep -E ':(3000|8080|3001)'
# Erwartet: 127.0.0.1:3000, nicht 0.0.0.0:3000

2. Uploads brechen bei einer bestimmten Größe ab. client_max_body_size in nginx steht standardmäßig auf 1 MB. Bei Caddy gibt es kein solches Limit, dafür oft eines in der Anwendung.

3. Der Live-Teil der Anwendung funktioniert nicht. Fehlende WebSocket-Header in nginx. Caddy macht das von selbst.

Prüfen, ob es sitzt#

# Zertifikatskette und Ablauf
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# Kopfzeilen ansehen
curl -sI https://app.example.com | grep -Ei 'strict-transport|x-content-type|server'

# Leitet HTTP wirklich um?
curl -sI http://app.example.com | head -1

Für einen vollständigen TLS-Bericht lohnt einmalig ein externer Prüfdienst, dort fallen Kettenprobleme auf, die lokal unauffällig sind.

Wenn Docker dahinter läuft#

Statt 127.0.0.1:3000 verweist der Proxy dann auf den veröffentlichten lokalen Port des Containers. Die Compose-Seite dazu, Ports nur lokal binden, interne Netze trennen, steht in Docker im echten Betrieb.

Ein Proxy auf dem Host statt im Container hat einen praktischen Vorteil: Er läuft weiter, wenn der Docker-Dienst neu startet, und seine Zertifikate liegen an einem Ort, den man ohne Container-Wissen sichert.

MRMedia

Geschrieben aus dem laufenden Betrieb: MRMedia betreibt eigene Proxmox- Hosts, einen Backup-Server und Kundendienste in der EU. Fehler in einer Anleitung sind Fehler im eigenen Betrieb. Korrekturen bitte über Discord.