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