- Caddy: custom image with caddy-ratelimit plugin, security headers (X-Frame-Options, HSTS, CSP-adjacent, etc.), per-IP rate limiting on auth/scrape/global zones, static error pages (502/503/504), fix routing to remove /api/scrape/* and /api/chapter-text-preview/* direct-to-backend (were bypassing SvelteKit auth middleware) - docker-compose: Caddy build context + error volume, Watchtower service (label-enable mode, 5 min poll), watchtower labels on backend/runner/ui - Scraper: ScrapeChapterList uses retryGet (9 attempts, Retry-After backoff) to fix 429-induced chapter list failures; upTo param stops pagination early for range scrapes - UI: Browse→Catalogue rename (routes, API, links), admin scrape page Continue/Retry buttons, +error.svelte branded error page, type cleanup (removed dead exports, added BookPreviewMeta/BookPreviewResponse to scraper.ts) - Meilisearch: meta_updated field, sort=update fix, facet distribution - Docs: reorganise into docs/d2/ and docs/mermaid/ subdirectories, update all diagrams to reflect Caddy/Watchtower/routing changes, add api-routing.d2 ownership map with auth-level colour coding, regenerate SVGs
302 lines
11 KiB
YAML
302 lines
11 KiB
YAML
# ── Shared environment fragments ──────────────────────────────────────────────
|
||
# These YAML anchors eliminate duplication between backend and runner.
|
||
# Both services talk to the same internal MinIO / PocketBase / Meilisearch /
|
||
# Valkey endpoints; only service-specific vars differ.
|
||
x-infra-env: &infra-env
|
||
# MinIO
|
||
MINIO_ENDPOINT: "minio:9000"
|
||
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}"
|
||
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||
MINIO_USE_SSL: "false"
|
||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-localhost}"
|
||
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-true}"
|
||
# PocketBase
|
||
POCKETBASE_URL: "http://pocketbase:8090"
|
||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||
# Meilisearch
|
||
MEILI_URL: "http://meilisearch:7700"
|
||
MEILI_API_KEY: "${MEILI_MASTER_KEY:-changeme_meili_123}"
|
||
# Valkey
|
||
VALKEY_ADDR: "valkey:6379"
|
||
|
||
services:
|
||
# ─── MinIO (object storage: chapters, audio, avatars, browse) ────────────────
|
||
minio:
|
||
image: minio/minio:latest
|
||
restart: unless-stopped
|
||
command: server /data --console-address ":9001"
|
||
environment:
|
||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||
# No public port — all presigned URL traffic goes through backend or a
|
||
# separately-exposed MINIO_PUBLIC_ENDPOINT (e.g. storage.libnovel.cc).
|
||
expose:
|
||
- "9000"
|
||
- "9001"
|
||
volumes:
|
||
- minio_data:/data
|
||
healthcheck:
|
||
test: ["CMD", "mc", "ready", "local"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
# ─── MinIO bucket initialisation ─────────────────────────────────────────────
|
||
minio-init:
|
||
image: minio/mc:latest
|
||
depends_on:
|
||
minio:
|
||
condition: service_healthy
|
||
entrypoint: >
|
||
/bin/sh -c "
|
||
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123};
|
||
mc mb --ignore-existing local/libnovel-chapters;
|
||
mc mb --ignore-existing local/libnovel-audio;
|
||
mc mb --ignore-existing local/avatars;
|
||
mc mb --ignore-existing local/libnovel-browse;
|
||
echo 'buckets ready';
|
||
"
|
||
environment:
|
||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||
|
||
# ─── PocketBase (auth + structured data) ─────────────────────────────────────
|
||
pocketbase:
|
||
image: ghcr.io/muchobien/pocketbase:latest
|
||
restart: unless-stopped
|
||
environment:
|
||
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||
# No public port — accessed only by backend/runner on the internal network.
|
||
expose:
|
||
- "8090"
|
||
volumes:
|
||
- pb_data:/pb_data
|
||
healthcheck:
|
||
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
# ─── PocketBase collection bootstrap ─────────────────────────────────────────
|
||
pb-init:
|
||
image: alpine:3.19
|
||
depends_on:
|
||
pocketbase:
|
||
condition: service_healthy
|
||
environment:
|
||
POCKETBASE_URL: "http://pocketbase:8090"
|
||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||
volumes:
|
||
- ./scripts/pb-init-v3.sh:/pb-init.sh:ro
|
||
entrypoint: ["sh", "/pb-init.sh"]
|
||
|
||
# ─── Meilisearch (full-text search) ──────────────────────────────────────────
|
||
meilisearch:
|
||
image: getmeili/meilisearch:latest
|
||
restart: unless-stopped
|
||
environment:
|
||
MEILI_MASTER_KEY: "${MEILI_MASTER_KEY:-changeme_meili_123}"
|
||
MEILI_ENV: "${MEILI_ENV:-production}"
|
||
# No public port — backend/runner reach it via internal network.
|
||
expose:
|
||
- "7700"
|
||
volumes:
|
||
- meili_data:/meili_data
|
||
healthcheck:
|
||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7700/health"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
# ─── Valkey (presign URL cache) ───────────────────────────────────────────────
|
||
valkey:
|
||
image: valkey/valkey:7-alpine
|
||
restart: unless-stopped
|
||
# No public port — backend/runner/ui reach it via internal network.
|
||
expose:
|
||
- "6379"
|
||
volumes:
|
||
- valkey_data:/data
|
||
healthcheck:
|
||
test: ["CMD", "valkey-cli", "ping"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
# ─── Backend API ──────────────────────────────────────────────────────────────
|
||
backend:
|
||
build:
|
||
context: ./backend
|
||
dockerfile: Dockerfile
|
||
target: backend
|
||
args:
|
||
VERSION: "${GIT_TAG:-dev}"
|
||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||
labels:
|
||
com.centurylinklabs.watchtower.enable: "true"
|
||
restart: unless-stopped
|
||
stop_grace_period: 35s
|
||
depends_on:
|
||
pb-init:
|
||
condition: service_completed_successfully
|
||
pocketbase:
|
||
condition: service_healthy
|
||
minio:
|
||
condition: service_healthy
|
||
meilisearch:
|
||
condition: service_healthy
|
||
valkey:
|
||
condition: service_healthy
|
||
# No public port — all traffic is routed via Caddy.
|
||
expose:
|
||
- "8080"
|
||
environment:
|
||
<<: *infra-env
|
||
BACKEND_HTTP_ADDR: ":8080"
|
||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||
healthcheck:
|
||
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
|
||
interval: 15s
|
||
timeout: 5s
|
||
retries: 3
|
||
|
||
# ─── Runner (background task worker) ─────────────────────────────────────────
|
||
runner:
|
||
build:
|
||
context: ./backend
|
||
dockerfile: Dockerfile
|
||
target: runner
|
||
args:
|
||
VERSION: "${GIT_TAG:-dev}"
|
||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||
labels:
|
||
com.centurylinklabs.watchtower.enable: "true"
|
||
restart: unless-stopped
|
||
stop_grace_period: 135s
|
||
depends_on:
|
||
pb-init:
|
||
condition: service_completed_successfully
|
||
pocketbase:
|
||
condition: service_healthy
|
||
minio:
|
||
condition: service_healthy
|
||
meilisearch:
|
||
condition: service_healthy
|
||
valkey:
|
||
condition: service_healthy
|
||
# Metrics endpoint — internal only; expose publicly via Caddy if needed.
|
||
expose:
|
||
- "9091"
|
||
environment:
|
||
<<: *infra-env
|
||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||
# Runner tuning
|
||
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL:-30s}"
|
||
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE:-1}"
|
||
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO:-1}"
|
||
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID:-runner-1}"
|
||
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT:-90s}"
|
||
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR:-:9091}"
|
||
# Kokoro-FastAPI TTS endpoint
|
||
KOKORO_URL: "${KOKORO_URL:-}"
|
||
KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}"
|
||
healthcheck:
|
||
# The runner writes /tmp/runner.alive on every poll.
|
||
# 120s = 2× the default 30s poll interval with generous headroom.
|
||
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||
interval: 60s
|
||
timeout: 5s
|
||
retries: 3
|
||
|
||
# ─── SvelteKit UI ─────────────────────────────────────────────────────────────
|
||
ui:
|
||
build:
|
||
context: ./ui
|
||
dockerfile: Dockerfile
|
||
args:
|
||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
||
labels:
|
||
com.centurylinklabs.watchtower.enable: "true"
|
||
restart: unless-stopped
|
||
stop_grace_period: 35s
|
||
depends_on:
|
||
pb-init:
|
||
condition: service_completed_successfully
|
||
backend:
|
||
condition: service_healthy
|
||
pocketbase:
|
||
condition: service_healthy
|
||
valkey:
|
||
condition: service_healthy
|
||
# No public port — all traffic via Caddy.
|
||
expose:
|
||
- "3000"
|
||
environment:
|
||
# ORIGIN must match the public URL Caddy serves on.
|
||
# adapter-node uses this for SvelteKit's built-in CSRF origin check.
|
||
ORIGIN: "${ORIGIN:-https://${DOMAIN:-localhost}}"
|
||
BACKEND_API_URL: "http://backend:8080"
|
||
POCKETBASE_URL: "http://pocketbase:8090"
|
||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||
AUTH_SECRET: "${AUTH_SECRET:-dev_secret_change_in_production}"
|
||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-https://localhost}"
|
||
# Valkey
|
||
VALKEY_ADDR: "valkey:6379"
|
||
healthcheck:
|
||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||
interval: 15s
|
||
timeout: 5s
|
||
retries: 3
|
||
|
||
# ─── Caddy (reverse proxy + automatic HTTPS) ──────────────────────────────────
|
||
# Custom build includes github.com/mholt/caddy-ratelimit.
|
||
caddy:
|
||
build:
|
||
context: ./caddy
|
||
dockerfile: Dockerfile
|
||
restart: unless-stopped
|
||
depends_on:
|
||
backend:
|
||
condition: service_healthy
|
||
ui:
|
||
condition: service_healthy
|
||
ports:
|
||
- "80:80"
|
||
- "443:443"
|
||
- "443:443/udp" # HTTP/3 (QUIC)
|
||
environment:
|
||
DOMAIN: "${DOMAIN:-localhost}"
|
||
CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL:-}"
|
||
volumes:
|
||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||
- ./caddy/errors:/srv/errors:ro
|
||
- caddy_data:/data
|
||
- caddy_config:/config
|
||
|
||
# ─── Watchtower (auto-redeploy custom services on new images) ────────────────
|
||
# Only watches services labelled com.centurylinklabs.watchtower.enable=true.
|
||
# Third-party infra images (minio, pocketbase, meilisearch, etc.) are excluded.
|
||
watchtower:
|
||
image: containrrr/watchtower:latest
|
||
restart: unless-stopped
|
||
volumes:
|
||
- /var/run/docker.sock:/var/run/docker.sock
|
||
command: --label-enable --interval 300 --cleanup
|
||
environment:
|
||
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS:-}"
|
||
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL:-}"
|
||
DOCKER_API_VERSION: "1.44"
|
||
|
||
volumes:
|
||
minio_data:
|
||
pb_data:
|
||
meili_data:
|
||
valkey_data:
|
||
caddy_data:
|
||
caddy_config:
|