# ~$ 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 # episode 15: the Kuma UI only behind the login gate (tinyauth, group ops). # Status pages, badges and push heartbeats stay public. @public path /status/* /api/status-page/* /api/badge/* /api/push/* /assets/* /upload/* /icon.svg /favicon.ico /manifest.json handle @public { reverse_proxy uptime-kuma:3001 } handle { forward_auth tinyauth:3000 { uri /api/auth/caddy copy_headers Remote-User Remote-Email Remote-Groups # tinyauth v5 answers 401 and names its login page in a header; # without this the browser only ever sees {"message":"Unauthorized"} @login status 401 handle_response @login { redir {rp.header.X-Tinyauth-Location} 302 } } 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 } # Identity provider (episode 15). Everyone logs in here, with a passkey. id.{$DOMAIN} { import common import crowdsec reverse_proxy pocket-id:1411 } # Login gate for services without their own SSO (episode 15), used by forward_auth. auth.{$DOMAIN} { import common import crowdsec reverse_proxy tinyauth:3000 }