# v3/Caddyfile # # Caddy reverse proxy for LibNovel v3. # Custom build includes github.com/mholt/caddy-ratelimit. # # Environment variables consumed (set in docker-compose.yml): # DOMAIN — public hostname, e.g. libnovel.example.com # Use "localhost" for local dev (no TLS cert attempted). # CADDY_ACME_EMAIL — Let's Encrypt notification email (empty = no email) # # Routing rules (main domain): # /health → backend:8080 (liveness probe) # /scrape* → backend:8080 (Go admin scrape endpoints) # /api/book-preview/* → backend:8080 (live scrape, no store write) # /api/chapter-text/* → backend:8080 (chapter markdown from MinIO) # /api/chapter-markdown/* → backend:8080 (chapter markdown from MinIO) # /api/reindex/* → backend:8080 (rebuild chapter index) # /api/cover/* → backend:8080 (proxy cover image) # /api/audio-proxy/* → backend:8080 (proxy generated audio) # /avatars/* → minio:9000 (presigned avatar GETs) # /audio/* → minio:9000 (presigned audio GETs) # /chapters/* → minio:9000 (presigned chapter GETs) # /* (everything else) → ui:3000 (SvelteKit — handles all # remaining /api/* routes) # # Subdomain routing: # feedback.libnovel.cc → fider:3000 (user feedback / feature requests) # errors.libnovel.cc → glitchtip-web:8000 (error tracking) # analytics.libnovel.cc → umami:3000 (page analytics) # logs.libnovel.cc → dozzle:8080 (Docker log viewer) # uptime.libnovel.cc → uptime-kuma:3001 (uptime monitoring) # push.libnovel.cc → gotify:80 (push notifications) # search.libnovel.cc → meilisearch:7700 (search index — homelab runner) # # Routes intentionally removed from direct-to-backend: # /api/scrape/* — SvelteKit has /api/scrape/ counterparts # that enforce auth; routing directly would # bypass SK middleware. # /api/chapter-text-preview/* — Same: SvelteKit owns # /api/chapter-text-preview/[slug]/[n]. # /api/browse — Endpoint removed; browse snapshot system # was deleted. { # Email for Let's Encrypt ACME account registration. # When CADDY_ACME_EMAIL is set this expands to e.g. "email you@example.com". # When unset the variable expands to an empty string and Caddy ignores it. email {$CADDY_ACME_EMAIL:} # CrowdSec bouncer — streams decisions from the CrowdSec LAPI every 15s. # CROWDSEC_API_KEY is injected at runtime via crowdsec/.crowdsec.env. # The default "disabled" placeholder makes the bouncer fail-open (warn, # pass traffic) when no key is configured — Caddy still starts cleanly. crowdsec { api_url http://crowdsec:8080 api_key {$CROWDSEC_API_KEY:disabled} ticker_interval 15s } # ── Redis TCP proxy via layer4 ──────────────────────────────────────────── # Exposes prod Redis over TLS for Asynq job enqueueing from the homelab runner. # Listens on :6380 (all interfaces). TLS is terminated here using the cert # for redis.libnovel.cc; traffic is proxied to the local Redis sidecar. # Requires the caddy-l4 module in the custom Caddy build. layer4 { :6380 { route { tls { connection_policy { match { sni redis.libnovel.cc } } } proxy { upstream redis:6379 } } } } } (security_headers) { header { # Prevent clickjacking X-Frame-Options "SAMEORIGIN" # Prevent MIME-type sniffing X-Content-Type-Options "nosniff" # Minimal referrer info for cross-origin requests Referrer-Policy "strict-origin-when-cross-origin" # Restrict powerful browser features Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" # Enforce HTTPS for 1 year (includeSubDomains) Strict-Transport-Security "max-age=31536000; includeSubDomains" # Enable XSS filter in older browsers X-XSS-Protection "1; mode=block" # Remove server identity header -Server } } {$DOMAIN:localhost} { import security_headers # ── CrowdSec bouncer ────────────────────────────────────────────────────── # Checks every incoming request against CrowdSec decisions. # Banned IPs receive a 403; all others pass through unchanged. route { crowdsec } # ── Rate limiting ───────────────────────────────────────────────────────── # Auth endpoints: strict — 10 req/min per IP rate_limit { zone auth_zone { match { path /api/auth/login /api/auth/register /api/auth/change-password } key {remote_host} window 1m events 10 } } # Admin scrape endpoints: moderate — 20 req/min per IP rate_limit { zone scrape_zone { match { path /scrape* } key {remote_host} window 1m events 20 } } # Global: 300 req/min per IP (covers everything) rate_limit { zone global_zone { key {remote_host} window 1m events 300 } } # ── Liveness probe ──────────────────────────────────────────────────────── handle /health { reverse_proxy backend:8080 } # ── Scrape task creation (Go backend only) ──────────────────────────────── handle /scrape* { reverse_proxy backend:8080 } # ── Backend-only API paths ──────────────────────────────────────────────── # These paths are served exclusively by the Go backend and have no # SvelteKit counterpart. Routing them here skips SK intentionally. handle /api/book-preview/* { reverse_proxy backend:8080 } handle /api/chapter-text/* { reverse_proxy backend:8080 } handle /api/chapter-markdown/* { reverse_proxy backend:8080 } handle /api/reindex/* { reverse_proxy backend:8080 } handle /api/cover/* { reverse_proxy backend:8080 } handle /api/audio-proxy/* { reverse_proxy backend:8080 } # ── MinIO bucket paths (presigned URLs) ────────────────────────────────── # MinIO path-style presigned URLs include the bucket name as the first # path segment. MINIO_PUBLIC_ENDPOINT points here, so Caddy must proxy # these paths directly to MinIO — no auth layer needed (the presigned # signature itself enforces access and expiry). handle /avatars/* { reverse_proxy minio:9000 } handle /audio/* { reverse_proxy minio:9000 } handle /chapters/* { reverse_proxy minio:9000 } # ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ─────── handle { reverse_proxy ui:3000 { # Active health check: Caddy polls /health every 5 s and marks the # upstream down immediately when it fails. Combined with # lb_try_duration this means Watchtower container replacements # show the maintenance page within a few seconds instead of # hanging or returning a raw connection error to the browser. health_uri /health health_interval 5s health_timeout 2s health_status 200 # If the upstream is down, fail fast (don't retry for longer than # 3 s) and let Caddy's handle_errors 502/503 take over. lb_try_duration 3s } } # ── Caddy-level error pages ─────────────────────────────────────────────── # These fire when the upstream (backend or ui) is completely unreachable. # SvelteKit's own +error.svelte handles application-level errors (404, 500). handle_errors 404 { root * /srv/errors rewrite * /404.html file_server } handle_errors 500 { root * /srv/errors rewrite * /500.html file_server } handle_errors 502 { root * /srv/errors rewrite * /502.html file_server } handle_errors 503 { root * /srv/errors rewrite * /503.html file_server } handle_errors 504 { root * /srv/errors rewrite * /504.html file_server } # ── Logging ─────────────────────────────────────────────────────────────── # JSON log file read by CrowdSec for threat detection. log { output file /var/log/caddy/access.log { roll_size 100MiB roll_keep 5 roll_keep_for 720h } format json } } # ── Tooling subdomains ──────────────────────────────────────────────────────── # feedback.libnovel.cc, errors.libnovel.cc, analytics.libnovel.cc, # logs.libnovel.cc, uptime.libnovel.cc, push.libnovel.cc, grafana.libnovel.cc # are now routed via Cloudflare Tunnel directly to the homelab (192.168.0.109). # No Caddy rules needed here — Cloudflare handles TLS termination and routing. # ── PocketBase: exposed for homelab runner task polling ─────────────────────── # Allows the homelab runner to claim tasks and write results via the PB API. # Admin UI is also accessible here for convenience. pb.libnovel.cc { import security_headers reverse_proxy pocketbase:8090 } # ── MinIO S3 API: exposed for homelab runner object writes ──────────────────── # The homelab runner connects here as MINIO_ENDPOINT to PutObject audio/chapters. # Also used as MINIO_PUBLIC_ENDPOINT for presigned URL generation. storage.libnovel.cc { import security_headers reverse_proxy minio:9000 } # ── Meilisearch: exposed for homelab runner search indexing ────────────────── # The homelab runner connects here as MEILI_URL to index books after scraping. # Protected by MEILI_MASTER_KEY bearer token — Meilisearch enforces auth on # every request; Caddy just terminates TLS. search.libnovel.cc { import security_headers reverse_proxy meilisearch:7700 } # ── Redis TLS cert anchor ───────────────────────────────────────────────────── # This virtual host exists solely so Caddy obtains and caches a TLS certificate # for redis.libnovel.cc. The layer4 block above uses that cert to terminate TLS # on :6380 (Asynq job-queue channel from prod → homelab Redis). # The HTTP route itself just returns 404 — no real traffic expected here. redis.libnovel.cc { respond 404 }