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übersetztsites.yamlin ein einfaches, tab-getrenntes Zwischenformat (bewusst ohne gawk-spezifische Features geschrieben, läuft also mit mawk, gawk und busybox-awk gleichermaßen).generate.shliest dieses Zwischenformat in Bash-Arrays, validiert (CIDR-Format, doppelte Namen/Ports, reservierte Ports, gültigertls_mode) und schreibtCaddyfile,haproxy.cfg, dieacl/*.lst-Dateien unddocker-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. Derlisten_portwird automatisch ingenerated/docker-compose.override.ymlfreigegeben (viaCOMPOSE_FILEin.envbei jedemdocker compose-Aufruf mitgeladen) - kein manuelles Anfassen derdocker-compose.ymlnötig.allowed_ips:weglassen bzw. leer lassen = öffentlich erreichbar.generate.shvalidiert jede Liste (CIDR-Format), erkennt doppelte Domain-/Service-Namen und doppelt vergebene Ports, und verweigert Port 80/443 fürtcp_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):
- Das Projekt selbst -
git pull --ff-onlydirekt 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). - Die Images -
haproxy/caddywerden neu gepullt, bleiben aber auf der in.envgepinnten Minor-Version (z.B.haproxy:3.2-alpine). Nur neue Patch-/Security-Builds, kein automatischer Sprung auf3.3. Größere Versionswechsel: Tag in.envändern, dannmake 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 abgelehnt1- Update erfolgreich angewendet2- 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
tmpfsfür/tmpbzw. dedizierten Volumes (Caddy-Zertifikate incaddy_data). - security_opt: no-new-privileges.
- Healthchecks: HAProxy validiert die eigene Config (
haproxy -c), Caddy prüft zumindest die Prozess-Lauffähigkeit. generate.shlä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.