Files
2026-07-31 22:17:22 +02:00

14 KiB

Reverse-Proxy-Stack: HAProxy + Caddy

Ein schlanker, gehärteter Edge-Layer, der sich vor beliebig viele andere docker-compose-Projekte schalten lässt (Mailcow, Checkmk, eigene Apps, ...).

Eine einzige Konfigurationsdatei (config/sites.yaml) steuert beide Dienste. Generiert wird per reinem Bash+AWK-Skript (generate.sh) direkt auf dem Host - kein Python, kein zusätzliches Docker-Image, keine Build-Schritte. bash und awk sind auf jedem Linux-System sowieso vorhanden.

Architektur

Internet
   │
   ▼
┌─────────────────────────────────────────┐
│ HAProxy (edge-haproxy)                  │  Ports 80/443(/weitere) öffentlich
│  - IP-Filterung auf TCP-Ebene           │
│    (per SNI, VOR dem TLS-Handshake)     │
│  - reiner Passthrough, keine            │
│    TLS-Terminierung                     │
└─────────────────────────────────────────┘
   │ (internes "edge"-Netzwerk)
   ▼
┌─────────────────────────────────────────┐
│ Caddy (edge-caddy)                      │  nur intern erreichbar
│  - TLS-Terminierung, automatisch        │
│    Let's Encrypt je Domain              │
│  - Host-basiertes Routing zu Backends   │
└─────────────────────────────────────────┘
   │
   ▼
Backend-Container anderer Compose-Projekte
(im selben "edge"-Netzwerk, z.B. Mailcow, Checkmk, ...)

Warum diese Aufteilung und nicht nur Caddy allein?

  • HAProxy filtert unerwünschte IPs bereits beim TCP-Handshake anhand des SNI-Felds - noch bevor Ressourcen für TLS-Termination oder HTTP-Parsing verbraucht werden.
  • Caddy bleibt für das, worin es am stärksten ist: automatisches HTTPS ohne manuelle Zertifikatsverwaltung, sauberes Host-Routing.
  • ACME-HTTP-01-Challenges laufen unabhängig von der IP-Filterung immer durch
    • damit euch nicht das passiert, was beim Mailcow-Setup mit der Hairpin-NAT/ACME-Problematik schon mal Kopfzerbrechen bereitet hat.

Warum Bash+AWK statt Python-Generator-Container?

Die Vorgängerversion nutzte einen kleinen Python/Jinja2-Container zum Generieren. Funktional identisch, aber: eigenes Dockerfile, eigener Build, eigenes Image im Umlauf - für eine reine Text-Transformation unverhältnismäßig viel Gewicht. generate.sh macht exakt dasselbe mit Bordmitteln:

  • lib/parse-sites.awk übersetzt sites.yaml in ein einfaches, tab-getrenntes Zwischenformat (bewusst ohne gawk-spezifische Features geschrieben, läuft also mit mawk, gawk und busybox-awk gleichermaßen).
  • generate.sh liest dieses Zwischenformat in Bash-Arrays, validiert (CIDR-Format, doppelte Namen/Ports, reservierte Ports, gültiger tls_mode) und schreibt Caddyfile, haproxy.cfg, die acl/*.lst-Dateien und docker-compose.override.yml.

Preis dafür: sites.yaml muss einem festen, dokumentierten Einrückungs- und Formatstil folgen (siehe Kopf der Datei) - der Parser ist bewusst simpel gehalten und kein vollwertiger YAML-Parser. Solange ihr die Datei nach dem Muster der bestehenden Einträge erweitert, ist das in der Praxis kein Problem.

Einmalige Einrichtung

Verzeichnismodell: Git-Checkout = Laufzeitverzeichnis

Analog zu mailcow-dockerized: kein separates Deployment-Verzeichnis, kein Sync-Schritt. Der Git-Checkout ist der Ort, an dem der Stack läuft.

# Auf dem Server, einmalig:
cd /opt
git clone <eure-repo-url> reverse-proxy
cd reverse-proxy

docker network create edge      # einmalig, falls noch nicht geschehen
make up

WICHTIG: config/sites.yaml ändert ihr direkt hier und committet es - update.sh zieht künftige Änderungen per git pull --ff-only (siehe "Automatische Updates" unten). Lokale, nicht committete Änderungen werden nie überschrieben - ein --ff-only-Pull schlägt in dem Fall einfach fehl und wird im Log als Warnung vermerkt, statt etwas zu riskieren.

Andere Compose-Projekte anbinden

Damit andere Compose-Projekte über diesen Proxy erreichbar sind, müssen sie sich im selben Netzwerk befinden. In deren docker-compose.yml:

services:
  irgendein-dienst:
    # ...
    networks:
      - edge

networks:
  edge:
    external: true

Der Backend-Name in config/sites.yaml (backend: irgendein-dienst:8080) muss dann dem Servicenamen/Containernamen im edge-Netzwerk entsprechen.

Alltägliche Bedienung

Aufgabe Befehl
Neue Domain hinzufügen sites.yaml bearbeiten, dann make reload
IP-Filterung für Domain/Port ändern allowed_ips: bei der jeweiligen Domain bzw. dem tcp_service anpassen, dann make reload
Configs nur generieren (ohne Neustart) make generate
Configs vor dem Ausrollen validieren make validate
Logs ansehen make logs
Stack stoppen make down

make reload generiert die Configs neu (Bash, lokal, sofort), validiert Caddyfile (caddy validate) und haproxy.cfg (haproxy -c) und bricht bei Fehlern ab, bevor irgendwas neu gestartet wird.

IP-Filterung: pro Backend, unabhängig voneinander

Jede Domain und jeder TCP-Service in config/sites.yaml hat eine eigene allowed_ips-Liste. Kein globaler Schalter - die Filterung ergibt sich einfach daraus, ob bei einem Eintrag eine Liste steht oder nicht:

domains:
  - name: git.abw-weiland.de
    backend: gitea:3000
    backend_scheme: http
    backend_tls_insecure: false
    tls_mode: auto
    allowed_ips:
      - 203.0.113.0/24        # Büro
      - 198.51.100.10/32      # Homeoffice

  - name: monitor.abw-weiland.de
    backend: checkmk:5000
    backend_scheme: http
    backend_tls_insecure: false
    tls_mode: auto
    allowed_ips:
      - 10.0.0.0/8            # CheckMK-Web-UI nur aus dem eigenen Netz

tcp_services:
  - name: checkmk-agent-push
    listen_port: 6557
    backend: checkmk:6557
    allowed_ips:
      - 10.10.0.0/24           # Agent-Port: ANDERE IPs als die Web-UI oben
  • Domains laufen über Caddy/HTTPS. Die IP-Filterung passiert in HAProxy zweifach: auf Port 443 anhand des SNI (vor dem TLS-Handshake) und auf Port 80 anhand des Host-Headers - jeweils mit Ausnahme des ACME-Challenge-Pfads.
  • tcp_services: sind rohe TCP-Ports ohne Caddy dazwischen - für Dienste, die nicht per Domain/SNI laufen, z.B. einen CheckMK-Agent-Push-Port. Der listen_port wird automatisch in generated/docker-compose.override.yml freigegeben (via COMPOSE_FILE in .env bei jedem docker compose-Aufruf mitgeladen) - kein manuelles Anfassen der docker-compose.yml nötig.
  • allowed_ips: weglassen bzw. leer lassen = öffentlich erreichbar.
  • generate.sh validiert jede Liste (CIDR-Format), erkennt doppelte Domain-/Service-Namen und doppelt vergebene Ports, und verweigert Port 80/443 für tcp_services (die sind für Caddy/ACME reserviert) - alles vor jedem Deployment.

TLS-Modus: ob wirklich ein Zertifikat erstellt wird

Pro Domain über tls_mode steuerbar:

Wert (Standard: auto) Bedeutung
auto Echtes Let's-Encrypt-Zertifikat, öffentlich vertrauenswürdig. Braucht funktionierendes DNS + offenen Port 80.
internal Caddys eigene interne CA - selbstsigniert, kein ACME-Request, keine Rate-Limits. Browser zeigen eine Zertifikatswarnung, außer die interne CA ist dort importiert. Gut zum Testen, ohne Let's-Encrypt-Rate-Limits zu riskieren.
"off" Kein TLS, nur HTTP (Port 80). Kein Zertifikat wird angefragt. Sinnvoll z.B. wenn DNS noch nicht steht.

Wichtig: "off" immer in Anführungszeichen schreiben - unquotiert liest YAML es als Boolean false. Der Generator normalisiert das zwar über stripq(), sauberer ist es aber, es gleich richtig zu schreiben.

Bei tls_mode: "off" entfällt die Domain automatisch aus der SNI-basierten IP-Filterung auf Port 443 (läuft dort ohnehin nicht) - eine gesetzte allowed_ips-Liste wirkt dann nur noch auf Port 80.

Automatische Updates

update/update.sh orientiert sich am Update-Skript von mailcow-dockerized (interaktive Bestätigung, Hook-Punkte), angepasst auf unseren Fall (2-Container-Edge-Stack statt Dutzende interdependenter Container):

  1. Das Projekt selbst - git pull --ff-only direkt in diesem Verzeichnis (siehe "Verzeichnismodell" oben). Ohne Git-Checkout wird der Schritt übersprungen (Info im Log). Fast-forward-only heißt: lokale, nicht committete Änderungen werden nie überschrieben, der Pull schlägt in dem Fall einfach fehl (Warnung im Log).
  2. Die Images - haproxy/caddy werden neu gepullt, bleiben aber auf der in .env gepinnten Minor-Version (z.B. haproxy:3.2-alpine). Nur neue Patch-/Security-Builds, kein automatischer Sprung auf 3.3. Größere Versionswechsel: Tag in .env ändern, dann make reload.

Interaktive Bestätigung: Werden Änderungen gefunden, fragt das Skript (wie bei mailcow) Update anwenden? Caddy und HAProxy werden neu gestartet. [y/N], bevor es etwas anfasst - außer ihr übergebt -f/--force/--yes. Läuft das Skript nicht-interaktiv (Cron/systemd) und ohne --force, bricht es sauber mit Exit-Code 2 ab, statt zu hängen oder blind anzuwenden.

Hooks (optional, siehe update/*_update_hook.sh.example): Existiert update/pre_update_hook.sh, läuft es ganz am Anfang. Existiert update/post_update_hook.sh, läuft es am Ende - aber nur, wenn tatsächlich ein Update angewendet wurde (erfolgreich oder mit Rollback), nicht bei "keine Änderungen" oder --dry-run. Beide Dateien sind git-ignoriert (hostspezifisch) - Vorlage kopieren und anpassen: cp update/pre_update_hook.sh.example update/pre_update_hook.sh.

Ablauf bei erkannten Änderungen:

git pull --ff-only -> Images pullen -> [Bestätigung, außer --force]
   -> generate.sh (Bash) -> Caddyfile + haproxy.cfg validieren
   -> Container neu starten -> Healthcheck abwarten (60s)
       -> healthy: fertig
       -> nicht healthy: automatisches Rollback auf die vorherigen
          Image-IDs, erneuter Healthcheck, Ergebnis geloggt

Nichts wird neu gestartet, solange Generierung oder Validierung fehlschlagen. Anders als mailcow (das Rollback nur manuell per git reflog anbietet) macht dieser Stack das Rollback automatisch - bei nur zwei Containern und der Rolle als Eingangstor für alles dahinter ist ein automatischer Recovery-Pfad hier wichtiger als bei einem großen, komplexen Stack.

Manuell ausführen:

update/update.sh                # interaktiv, fragt vor dem Anwenden nach
make update-check                # zeigt verfügbare Updates, fragt nichts (--dry-run)
make update                      # wendet ohne Rückfrage an (--force, für Cron/systemd)

Log: update/update.log (einfache Größenrotation bei 5 MB).

Exit-Codes (für Monitoring, z.B. als CheckMK-Local-Check einbindbar):

  • 0 - keine Änderungen, Dry-Run ohne Befund, oder vom Nutzer abgelehnt
  • 1 - Update erfolgreich angewendet
  • 2 - Update fehlgeschlagen (mit oder ohne erfolgreiches Rollback), oder Bestätigung erforderlich aber nicht möglich (nicht-interaktiv ohne --force)

Automatisierung per systemd-Timer (in update/ enthalten):

sudo cp update/reverse-proxy-update.service update/reverse-proxy-update.timer /etc/systemd/system/
# WorkingDirectory/ExecStart in der .service-Datei an euren Deploy-Pfad anpassen
sudo systemctl daemon-reload
sudo systemctl enable --now reverse-proxy-update.timer

Läuft standardmäßig sonntags 04:00 Uhr (± 30 Min. Zufallsversatz).

Hardening-Entscheidungen

  • Image-Pinning: caddy:2.11-alpine, haproxy:3.2-alpine (aktuelle LTS-Reihe, Support bis 2030-Q2). Für maximale Reproduzierbarkeit könnt ihr die Tags zusätzlich per Digest fixieren (docker inspect --format '{{index .RepoDigests 0}}' <image>).
  • cap_drop: ALL + gezieltes cap_add: NET_BIND_SERVICE.
  • read_only: true Root-Dateisystem für beide Container, mit tmpfs für /tmp bzw. dedizierten Volumes (Caddy-Zertifikate in caddy_data).
  • security_opt: no-new-privileges.
  • Healthchecks: HAProxy validiert die eigene Config (haproxy -c), Caddy prüft zumindest die Prozess-Lauffähigkeit.
  • generate.sh läuft komplett ohne Docker/Netzwerkzugriff - reines Text-Processing auf dem Host, kleinstmögliche Angriffsfläche für diesen Schritt.

Projektstruktur

reverse-proxy/
├── config/
│   └── sites.yaml            # <- einzige Datei, die ihr im Alltag bearbeitet
├── lib/
│   └── parse-sites.awk        # AWK-Parser (portabel: mawk/gawk/busybox)
├── generate.sh                 # Bash-Generator, kein Python/Docker nötig
├── generated/                  # Output, wird gemountet
│   ├── Caddyfile
│   ├── haproxy.cfg
│   ├── acl/                    # eine .lst-Datei je gefilterter Domain/Service
│   └── docker-compose.override.yml   # Portfreigaben für tcp_services
├── update/
│   ├── update.sh                        # Updater: Projekt + Images, mit Rollback
│   ├── update.log                       # wird beim ersten Lauf angelegt
│   ├── pre_update_hook.sh.example        # Vorlage, siehe "Automatische Updates"
│   ├── post_update_hook.sh.example       # Vorlage, siehe "Automatische Updates"
│   ├── reverse-proxy-update.service      # systemd-Unit
│   └── reverse-proxy-update.timer        # systemd-Timer (wöchentlich)
├── .env                       # Image-Tags + COMPOSE_FILE
├── docker-compose.yml
├── Makefile
└── README.md

generated/Caddyfile, generated/haproxy.cfg und generated/acl/*.lst sind reiner Build-Output und stehen in .gitignore. Ausnahme: generated/docker-compose.override.yml liegt als Platzhalter im Repo, weil .env (COMPOSE_FILE=...) schon beim allerersten docker compose-Aufruf darauf verweist, bevor make generate je gelaufen ist.