selfhostedops-stack/Caddyfile
Maximilian Grimm bed41cfaf3 Episode 10: hardening - firewall rules Docker cannot bypass, Caddy access log and rate limiting, fail2ban in the right chain
- hardening/: ufw after.rules block for DOCKER-USER (ctorigdstport, not dport),
  fail2ban jail.local with sshd (systemd backend) and caddy-probes (chain DOCKER-USER),
  filter for the JSON access log, sshd hardening.conf, README with the order and
  the two traps measured in the episode
- caddy/Dockerfile: xcaddy build with caddy-ratelimit, compose builds the image
- Caddyfile: common snippet (JSON access log, Server header stripped), vault /admin 404,
  rate_limit zones on the vault token endpoint and the git login form
- HARDENING.md: firewall and fail2ban sections rewritten around what the port scan saw

Claude-Session: https://claude.ai/code/session_017iP581CYtPfxdZYPPAyWZA
2026-09-11 10:20:56 +02:00

105 lines
2.5 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 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
}
git.{$DOMAIN} {
import common
# 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
reverse_proxy outline:3000
}
vault.{$DOMAIN} {
import common
# 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
reverse_proxy uptime-kuma:3001
}
# Push notifications (episode 6). Websockets and long polling pass through as-is.
ntfy.{$DOMAIN} {
import common
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
@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
}