# ── 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/chapters; mc mb --ignore-existing local/audio; mc mb --ignore-existing local/avatars; mc mb --ignore-existing local/catalogue; 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}" KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc/}" KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}" 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}" # Suppress the on-startup catalogue walk — catalogue_refresh now skips # books already in Meilisearch, so a full walk on every restart is wasteful. # The 24h periodic ticker (CatalogueRefreshInterval) still fires normally. RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true" # Kokoro-FastAPI TTS endpoint KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc/}" 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 # ─── CrowdSec (threat detection + IP blocking) ─────────────────────────────── # Reads Caddy JSON access logs from the shared caddy_logs volume and enforces # decisions via the Caddy bouncer plugin. crowdsec: image: crowdsecurity/crowdsec:latest restart: unless-stopped environment: GID: "1000" COLLECTIONS: "crowdsecurity/caddy crowdsecurity/http-dos crowdsecurity/base-http-scenarios" volumes: - crowdsec_data:/var/lib/crowdsec/data - ./crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml:ro - caddy_logs:/var/log/caddy:ro expose: - "8080" healthcheck: test: ["CMD", "cscli", "version"] interval: 20s timeout: 10s retries: 5 # ─── CrowdSec bouncer registration ─────────────────────────────────────────── # One-shot: registers the Caddy bouncer with the CrowdSec LAPI and writes the # generated API key to crowdsec/.crowdsec.env, which Caddy reads via env_file. # Uses the Docker socket to exec cscli inside the running crowdsec container. crowdsec-init: image: docker:cli depends_on: crowdsec: condition: service_healthy volumes: - /var/run/docker.sock:/var/run/docker.sock - ./crowdsec:/crowdsec-out entrypoint: > /bin/sh -c " out=/crowdsec-out/.crowdsec.env; existing=$$(grep -s '^CROWDSEC_API_KEY=.' \"$$out\" | cut -d= -f2-); if [ -n \"$$existing\" ]; then echo 'crowdsec-init: key already present, skipping registration'; exit 0; fi; container=$$(docker ps --filter name=crowdsec --filter status=running --format '{{.Names}}' | grep -v init | head -1); echo \"crowdsec-init: using container $$container\"; docker exec $$container cscli bouncers delete caddy-bouncer 2>/dev/null || true; key=$$(docker exec $$container cscli bouncers add caddy-bouncer -o raw 2>&1); if [ -z \"$$key\" ]; then echo 'crowdsec-init: ERROR — failed to obtain bouncer key' >&2; exit 1; fi; printf 'CROWDSEC_API_KEY=%s\n' \"$$key\" > \"$$out\"; echo \"crowdsec-init: bouncer key written (key length: $${#key})\"; " restart: "no" # ─── Caddy (reverse proxy + automatic HTTPS) ────────────────────────────────── # Custom build includes github.com/mholt/caddy-ratelimit and # github.com/hslatman/caddy-crowdsec-bouncer/http. caddy: build: context: ./caddy dockerfile: Dockerfile restart: unless-stopped depends_on: backend: condition: service_healthy ui: condition: service_healthy crowdsec-init: condition: service_completed_successfully ports: - "80:80" - "443:443" - "443:443/udp" # HTTP/3 (QUIC) environment: DOMAIN: "${DOMAIN:-localhost}" CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL:-}" env_file: - path: ./crowdsec/.crowdsec.env required: false volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/errors:/srv/errors:ro - caddy_data:/data - caddy_config:/config - caddy_logs:/var/log/caddy # ─── 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" # ─── Shared PostgreSQL (Fider + GlitchTip + Umami) ─────────────────────────── # A single Postgres instance hosting three separate databases. # PocketBase uses its own embedded SQLite; this postgres is only for the # three new services below. postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: "${POSTGRES_USER:-pgadmin}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-changeme_postgres_123}" POSTGRES_DB: postgres expose: - "5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER:-pgadmin}"] interval: 10s timeout: 5s retries: 5 # ─── Postgres database initialisation ──────────────────────────────────────── # One-shot: creates the fider, glitchtip, and umami databases if missing. postgres-init: image: postgres:16-alpine depends_on: postgres: condition: service_healthy environment: PGPASSWORD: "${POSTGRES_PASSWORD:-changeme_postgres_123}" entrypoint: > /bin/sh -c " psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='fider'\" | grep -q 1 || psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -c \"CREATE DATABASE fider\"; psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='glitchtip'\" | grep -q 1 || psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -c \"CREATE DATABASE glitchtip\"; psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='umami'\" | grep -q 1 || psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -c \"CREATE DATABASE umami\"; echo 'postgres-init: databases ready'; " restart: "no" # ─── Fider (user feedback & feature requests) ───────────────────────────────── fider: image: getfider/fider:stable restart: unless-stopped depends_on: postgres-init: condition: service_completed_successfully postgres: condition: service_healthy expose: - "3000" environment: BASE_URL: "https://feedback.libnovel.cc" DATABASE_URL: "postgres://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/fider?sslmode=disable" JWT_SECRET: "0cd21a84d03b36e730aa6ef5a24c1dd6cb01546187e6ca1161c90e3a167df52d" # Email: noreply mode — emails are suppressed (logged to stdout). # Fider still requires SMTP vars to be non-empty even in noreply mode. EMAIL_NOREPLY: "noreply@libnovel.cc" EMAIL_SMTP_HOST: "localhost" EMAIL_SMTP_PORT: "25" # Disable outbound email — set real SMTP values to enable. EMAIL_NOREPLY_MODE: "true" # ─── GlitchTip DB migration (one-shot) ─────────────────────────────────────── glitchtip-migrate: image: glitchtip/glitchtip:latest depends_on: postgres-init: condition: service_completed_successfully postgres: condition: service_healthy environment: DATABASE_URL: "postgres://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/glitchtip" SECRET_KEY: "d4936488eb40bafe5cd79f137133b3f642db5537dd3f80ab4c858984443163be" GLITCHTIP_DOMAIN: "https://errors.libnovel.cc" EMAIL_URL: "consolemail://" DEFAULT_FROM_EMAIL: "errors@libnovel.cc" VALKEY_URL: "redis://valkey:6379/1" command: "./manage.py migrate" restart: "no" # ─── GlitchTip web (error tracking UI + API) ───────────────────────────────── glitchtip-web: image: glitchtip/glitchtip:latest restart: unless-stopped depends_on: glitchtip-migrate: condition: service_completed_successfully valkey: condition: service_healthy expose: - "8000" environment: DATABASE_URL: "postgres://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/glitchtip" SECRET_KEY: "d4936488eb40bafe5cd79f137133b3f642db5537dd3f80ab4c858984443163be" GLITCHTIP_DOMAIN: "https://errors.libnovel.cc" EMAIL_URL: "consolemail://" DEFAULT_FROM_EMAIL: "errors@libnovel.cc" VALKEY_URL: "redis://valkey:6379/1" PORT: "8000" ENABLE_USER_REGISTRATION: "false" healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"] interval: 15s timeout: 5s retries: 5 # ─── GlitchTip worker (background task processor) ───────────────────────────── glitchtip-worker: image: glitchtip/glitchtip:latest restart: unless-stopped depends_on: glitchtip-migrate: condition: service_completed_successfully valkey: condition: service_healthy environment: DATABASE_URL: "postgres://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/glitchtip" SECRET_KEY: "d4936488eb40bafe5cd79f137133b3f642db5537dd3f80ab4c858984443163be" GLITCHTIP_DOMAIN: "https://errors.libnovel.cc" EMAIL_URL: "consolemail://" DEFAULT_FROM_EMAIL: "errors@libnovel.cc" VALKEY_URL: "redis://valkey:6379/1" SERVER_ROLE: "worker" # ─── Umami (page analytics) ─────────────────────────────────────────────────── umami: image: ghcr.io/umami-software/umami:postgresql-latest restart: unless-stopped depends_on: postgres-init: condition: service_completed_successfully postgres: condition: service_healthy expose: - "3000" environment: DATABASE_URL: "postgresql://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/umami" APP_SECRET: "e4a91a11027acfd0270539a67a405115eda3d0450827415900d775c183b83821" healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:3000/api/heartbeat"] interval: 15s timeout: 5s retries: 5 # ─── Dozzle (Docker log viewer) ─────────────────────────────────────────────── dozzle: image: amir20/dozzle:latest restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./dozzle/users.yml:/data/users.yml:ro expose: - "8080" environment: DOZZLE_AUTH_PROVIDER: simple DOZZLE_HOSTNAME: "logs.libnovel.cc" healthcheck: test: ["CMD", "/dozzle", "healthcheck"] interval: 15s timeout: 5s retries: 5 # ─── Uptime Kuma (uptime monitoring) ────────────────────────────────────────── uptime-kuma: image: louislam/uptime-kuma:1 restart: unless-stopped volumes: - uptime_kuma_data:/app/data expose: - "3001" healthcheck: test: ["CMD", "extra/healthcheck"] interval: 15s timeout: 5s retries: 5 # ─── Gotify (push notifications) ────────────────────────────────────────────── gotify: image: gotify/server:latest restart: unless-stopped volumes: - gotify_data:/app/data expose: - "80" environment: GOTIFY_DEFAULTUSER_NAME: "${GOTIFY_ADMIN_USER:-admin}" GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS:-changeme_gotify_123}" GOTIFY_SERVER_PORT: "80" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:80/health"] interval: 15s timeout: 5s retries: 5 volumes: minio_data: pb_data: meili_data: valkey_data: caddy_data: caddy_config: caddy_logs: crowdsec_data: postgres_data: uptime_kuma_data: gotify_data: