All checks were successful
CI / Backend (push) Successful in 46s
CI / UI (push) Successful in 52s
Release / Docker / caddy (push) Successful in 34s
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 49s
CI / Backend (pull_request) Successful in 25s
CI / UI (pull_request) Successful in 56s
Release / Docker / runner (push) Successful in 1m46s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / backend (push) Successful in 2m47s
Release / Gitea Release (push) Successful in 13s
- Add Redis sidecar to prod docker-compose; backend connects locally (redis:6379) - Caddy layer4 now proxies redis.libnovel.cc:6380 → local redis:6379 (not homelab LAN) - Remove HOMELAB_REDIS_ADDR; homelab runner connects out to prod Redis via rediss:// - Remove local Redis from homelab runner compose; drop redis_data volume - Fix config.Load() missing LibreTranslate section — LIBRETRANSLATE_URL was never read
292 lines
11 KiB
Caddyfile
292 lines
11 KiB
Caddyfile
# 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
|
|
}
|