# 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) # # 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 it expands to an empty string and is silently ignored. {$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 } } (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 } # ── 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 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 } } # ── Fider: user feedback & feature requests ─────────────────────────────────── feedback.libnovel.cc { import security_headers reverse_proxy fider:3000 } # ── GlitchTip: error tracking ───────────────────────────────────────────────── errors.libnovel.cc { import security_headers reverse_proxy glitchtip-web:8000 } # ── Umami: page analytics ───────────────────────────────────────────────────── analytics.libnovel.cc { import security_headers reverse_proxy umami:3000 } # ── Dozzle: Docker log viewer ───────────────────────────────────────────────── logs.libnovel.cc { import security_headers reverse_proxy dozzle:8080 } # ── Uptime Kuma: uptime monitoring ──────────────────────────────────────────── uptime.libnovel.cc { import security_headers reverse_proxy uptime-kuma:3001 } # ── Gotify: push notifications ──────────────────────────────────────────────── push.libnovel.cc { import security_headers reverse_proxy gotify:80 }