selfhostedops-stack/Caddyfile
Maximilian Grimm 88670f028f Episode 12/13: CrowdSec next to fail2ban, and an AppSec WAF in Caddy
Measured on the demo box against the same 24h of real traffic.

Episode 12 (detection, blocklist, firewall):
- crowdsec/ with install notes and the traps: port 8080 clash, empty first
  blocklist pull, replay forensic-mode dropping events (FIFO workaround),
  ssh-time-based-bf shipping remediation:false, the AppSec collections that
  refuse to start without extra rules.
- firewall bouncer in nftables mode (input + forward), which blocks
  Docker-published ports and survives ufw reload; iptables/DOCKER-USER does not.
- HARDENING.md episode 12 section with the numbers: fail2ban 4 banned IPs vs
  CrowdSec 23 detected / 1 blocked, blocklist overlap 50% of IPs but 19% of
  attempts, and the mode=normal blind spot for key-only root attempts.

Episode 13 (AppSec WAF):
- caddy/Dockerfile now builds the CrowdSec bouncer and AppSec handler alongside
  rate-limit, pinned to v0.14.1.
- Caddyfile imports a crowdsec snippet (bouncer + appsec) on every site except
  vpn; compose passes CROWDSEC_BOUNCER_KEY and host.docker.internal.
- Measured: virtual patching blocks known CVEs only (7 of 41 probes, near-zero
  false positives); OWASP CRS raises that to 27 of 41 but breaks git push and
  wiki editing at default paranoia. Default stays vpatch+generic.

fail2ban config unchanged. No secrets or real domains/IPs in tracked files.
2026-09-16 20:34:18 +02:00

127 lines
3.1 KiB
Caddyfile

# ~$ selfhostedops — Caddyfile
# Automatic HTTPS for every subdomain. Nothing to renew, ever.
{
email {$ACME_EMAIL}
# episode 10: rate_limit has to run before reverse_proxy hands the request on
order rate_limit before basic_auth
# episode 12: CrowdSec decides first (banned IP -> 403), then the AppSec WAF
# checks the request itself. Both talk to CrowdSec on the host.
order crowdsec first
order appsec after crowdsec
crowdsec {
api_url http://host.docker.internal:8090
api_key {$CROWDSEC_BOUNCER_KEY}
ticker_interval 10s
appsec_url http://host.docker.internal:7422
}
}
# episode 10: one snippet, imported into every site. JSON access log (fail2ban
# on the host reads it) and the Server header stripped so responses no longer
# announce Rocket/Caddy. client_ip in the log is the real remote address, so no
# trusted_proxies needed - the Docker DNAT already carries it through.
(common) {
log {
output file /var/log/caddy/access.log {
roll_size 50mb
roll_keep 5
}
format json
}
header -Server
}
# episode 12: blocklist + WAF. Imported everywhere except vpn. (NetBird gRPC streams).
(crowdsec) {
crowdsec
appsec
}
git.{$DOMAIN} {
import common
import crowdsec
# episode 10: throttle login attempts (10 per minute per source IP)
rate_limit {
zone git_login {
match {
method POST
path /user/login
}
key {remote_host}
events 10
window 1m
}
}
reverse_proxy forgejo:3000
}
wiki.{$DOMAIN} {
import common
import crowdsec
reverse_proxy outline:3000
}
vault.{$DOMAIN} {
import common
import crowdsec
# episode 10: the admin panel is a setup tool, not a public page (was 200)
@admin path /admin*
respond @admin 404
# episode 10: throttle the login endpoints (10 per minute per source IP)
rate_limit {
zone vault_login {
match {
path /identity/connect/token /api/accounts/prelogin
}
key {remote_host}
events 10
window 1m
}
}
reverse_proxy vaultwarden:80
}
status.{$DOMAIN} {
import common
import crowdsec
reverse_proxy uptime-kuma:3001
}
# Push notifications (episode 6). Websockets and long polling pass through as-is.
ntfy.{$DOMAIN} {
import common
import crowdsec
reverse_proxy ntfy:80
}
# Automation (episode 8). The editor never becomes public: only the webhook
# paths are served, everything else under this host answers an empty 404.
# A webhook sender out on the internet cannot join your mesh, so this is the
# one door that has to stay open - and it is exactly one door wide.
n8n.{$DOMAIN} {
import common
import crowdsec
@webhooks path /webhook/* /webhook-test/* /webhook-waiting/*
handle @webhooks {
reverse_proxy n8n:5678
}
handle {
respond 404
}
}
# NetBird control plane (episode 5) - dashboard, API, gRPC, relay, built-in IdP
vpn.{$DOMAIN} {
import common
# Native gRPC (needs HTTP/2 cleartext to backend)
@grpc header Content-Type application/grpc*
reverse_proxy @grpc h2c://netbird-server:80
# Combined server paths (relay, signal, management, OAuth2)
@backend path /relay* /ws-proxy/* /api/* /oauth2/*
reverse_proxy @backend netbird-server:80
# Dashboard (everything else)
reverse_proxy /* netbird-dashboard:80
}