# ~$ 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 }