Kein Login, trotzdem im Netz: Rate Limits für Chat und MCP

18.10.2026

Kein Login, trotzdem im Netz: Rate Limits für Chat und MCP Unter mcp.ainsicht.de/mcp läuft mein MCP-Server. Direkt darüber, unter mcp.ainsicht.de, liegt ein Web-Chat, der diesen Server nutzt, um Fragen zu Inhalten auf ainsicht.de zu beantworten.

Beides ist öffentlich erreichbar. Es gibt keinen Login, keinen API-Schlüssel, keine Registrierung.

Das ist Absicht: Hürden raus, ausprobieren soll sofort funktionieren. Aber ein Server mit offener Domain auf Port 443 ist im öffentlichen Netz sofort Freiwild. Scanner, Botnetze und Shodan warten nicht auf einen Eintrag bei Hacker News oder Reddit. Sie scannen IP-Bereiche ab, sobald DNS-Einträge existieren oder Zertifikate in Certificate-Transparency-Logs auftauchen.

Setup Caddy läuft im Docker-Container auf einem kleinen VPS als Ingress-Proxy. Er holt sich Let's Encrypt-Zertifikate und terminiert TLS.

Dahinter liegt der eigentliche Dienst nicht auf demselben Host. Der Origin-Server läuft in einem Podman-Container auf einem SBC bei mir lokal, angebunden über ein privates Tailscale-Netzwerk.

Internet
   │
   ▼
Caddy (Docker auf VPS, Port 80/443)
   │
   ▼  Tailscale VPN
Podman auf lokalem SBC (MCP + Chat)

Chat und MCP-Endpunkt teilen sich denselben Origin-Container. Das ist pragmatisch, hat aber einen Haken: Aus Sicht des Reverse Proxys ist ein Klick im Web-Interface erst einmal dasselbe wie ein direkter JSON-RPC-Call auf den MCP-Pfad.

Der automatisierte Müll Kaum war die Domain im DNS, liefen die ersten Zugriffe auf typische CMS- und Framework-Reste ein:

/wp-login.php
/.env
/.git/config
/actuator/health
/phpmyadmin

Das ist Grundrauschen. Wenn man nichts tut, leitet Caddy diesen Schrott brav durch den Tailscale-Tunnel bis auf den kleinen SBC durch. Ein SBC hat Besseres zu tun, als 404-Seiten für alte WordPress-Exploits zu rendern. Caddy soll den Kram sofort an der Haustür abweisen.

Das eigentliche Problem sitzt tiefer: Der Chat löst bei jeder Frage LLM-Inferenz aus. Der MCP-Endpunkt erlaubt Tool-Calls. Wenn jemand einen Script-Loop dagegen setzt, brennt nicht die CPU, sondern das Token-Budget oder das Rate-Limit des LLM-Backends.

Rate Limiting ist kein Auth-Ersatz Man muss das klar benennen: Ein IP-basiertes Rate Limit ist keine Sicherheit. Wer rotierende Proxys, IPv6-Präfixe oder ein Botnetz nutzt, umgeht IP-Zähler mühelos.

Was ein Rate Limit auf Proxy-Ebene tut:

Es bricht banale Brute-Force- und Benchmark-Tools ab.

Es fängt durchdrehende lokale Scripts oder fehlerhafte Agent-Schleifen ab.

Es schützt den Tunnel und den Origin vor trivialer Überlastung.

Nicht mehr und nicht weniger. Wer echten Zugriffsschutz will, braucht mTLS, OAuth oder API-Keys. Solange der Dienst aber bewusst anonym bleiben soll, ist das IP-Limit die einzige Bremse am Perimeter.

Problem: Das Standard-Image kann kein Rate Limiting Im offiziellen Image caddy:2 ist kein Rate-Limit-Modul enthalten. Versucht man die Direktive rate_limit im Caddyfile zu nutzen, quittiert Caddy den Dienst mit einem Syntaxfehler.

Die saubere Lösung ist ein Multi-Stage-Build mit xcaddy. Man nutzt das offizielle Builder-Image, kompiliert das Modul mholt/caddy-ratelimit hinein und schiebt das Binary in das reguläre Alpine-basierte Caddy-Image zurück.

# syntax=docker/dockerfile:1
FROM caddy:2-builder AS builder

RUN xcaddy build \
    --with github.com/mholt/caddy-ratelimit

FROM caddy:2

COPY --from=builder /usr/bin/caddy /usr/bin/caddy
services:
  caddy:
    build:
      context: .
      dockerfile: Dockerfile
    image: caddy-custom:latest
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      # Zieladresse des SBC im Tailscale-Netzwerk
      MCP_ORIGIN: "http://100.x.y.z:3000"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

Der Port des Origins (3000) ist auf dem Host des SBC gar nicht nach außen gebunden; er lauscht nur auf dem Tailscale-Interface.

Das Caddyfile Hier binde ich die Regeln zusammen:

{
    # Weist Caddy an, das Rate-Limiting vor Standard-Handlern auszuführen
    order rate_limit before basicauth
}

mcp.ainsicht.de {
    encode zstd gzip

    # Scanner-Müll direkt an der Kante mit 404 abwerfen
    @scanner {
        path /wp-login.php /wp-admin* /xmlrpc.php
        path /.env* /.git*
        path /actuator* /phpmyadmin*
    }
    respond @scanner 404

    # Direkter MCP-Endpunkt: strikter limitiert (Tools & RPC)
    @mcp path /mcp*
    rate_limit @mcp {
        zone mcp_limit {
            key {remote_host}
            window 1m
            events 20
        }
    }

    # Web-Chat: moderater Spielraum für normalen Dialog
    rate_limit {
        zone chat_limit {
            key {remote_host}
            window 1m
            events 60
        }
    }

    reverse_proxy {$MCP_ORIGIN}
}

Ein paar Details dazu:

Kein Header-Voodoo: Caddys reverse_proxy setzt X-Forwarded-For, X-Forwarded-Proto und X-Forwarded-Host automatisch korrekt. Manuelle header_up-Zeilen für Standard-Header sind überflüssig.

Reihenfolge via order: Der globale Block stellt sicher, dass Caddy die Direktive rate_limit an der richtigen Stelle in die interne Middleware-Kette einsortiert.

Zonen-Trennung: Ein Nutzer, der im Browser chattet, teilt sich den Zähler nicht mit jemandem, der per Skript Tool-Calls gegen /mcp abfeuert.

Testen lässt sich das nach dem Start mit:

docker compose exec caddy caddy list-modules | grep rate_limit

Gibt Caddy http.handlers.rate_limit aus, läuft das Modul.

Fazit Ein offener Chat lädt zum Tippen ein, ein offener MCP-Endpunkt lädt zum Automatisieren ein. Caddy fängt das Gröbste ab: Scanner prallen an 404-Regeln ab, bevor sie den VPN-Tunnel belasten, und schnelle Abfrageschleifen laufen in HTTP 429.

Das macht die Kiste nicht einbruchsicher, aber sie ist kein offenes Scheunentor mehr für Script-Kiddies und verirrte Crawler. Für ein offenes Experimentierfeld reicht genau dieses Maß an Pragmatismus.

← Zurück zur Übersicht