# LibNovel homelab # # Runs on 192.168.0.109. Hosts: # - libnovel runner (background task worker) # - tooling: GlitchTip, Umami, Fider, Dozzle, Uptime Kuma, Gotify # - observability: OTel Collector, Tempo, Loki, Prometheus, Grafana # - cloudflared tunnel (public subdomains via Cloudflare Zero Trust) # - shared Postgres for tooling DBs # # All secrets come from Doppler (project=libnovel, config=prd_homelab). # Run with: doppler run -- docker compose up -d # # Public subdomains (via Cloudflare Tunnel — no ports exposed to internet): # errors.libnovel.cc → glitchtip-web:8000 # analytics.libnovel.cc → umami:3000 # feedback.libnovel.cc → fider:3000 # logs.libnovel.cc → dozzle:8080 # uptime.libnovel.cc → uptime-kuma:3001 # push.libnovel.cc → gotify:80 # grafana.libnovel.cc → grafana:3000 services: # ── Cloudflare Tunnel ─────────────────────────────────────────────────────── # Outbound-only encrypted tunnel to Cloudflare. # Routes all public subdomains to their respective containers on this network. # No inbound ports needed — cloudflared initiates all connections outward. cloudflared: image: cloudflare/cloudflared:latest restart: unless-stopped command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN} environment: CLOUDFLARE_TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN}" # ── LibNovel Runner ───────────────────────────────────────────────────────── # Background task worker. Connects to prod PocketBase, MinIO, Meilisearch # via their public subdomains (pb.libnovel.cc, storage.libnovel.cc, etc.) runner: image: kalekber/libnovel-runner:latest restart: unless-stopped stop_grace_period: 135s labels: com.centurylinklabs.watchtower.enable: "true" environment: POCKETBASE_URL: "https://pb.libnovel.cc" POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}" POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}" MINIO_ENDPOINT: "storage.libnovel.cc" MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}" MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}" MINIO_USE_SSL: "true" MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}" MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}" MEILI_URL: "${MEILI_URL}" MEILI_API_KEY: "${MEILI_API_KEY}" VALKEY_ADDR: "" GODEBUG: "preferIPv4=1" KOKORO_URL: "http://kokoro-fastapi:8880" KOKORO_VOICE: "${KOKORO_VOICE}" POCKET_TTS_URL: "http://pocket-tts:8000" RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}" RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}" RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}" RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}" RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}" RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}" RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true" LOG_LEVEL: "${LOG_LEVEL}" GLITCHTIP_DSN: "${GLITCHTIP_DSN}" # OTel — send runner traces/metrics to the local collector (HTTP) OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318" OTEL_SERVICE_NAME: "runner" healthcheck: test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"] interval: 60s timeout: 5s retries: 3 # ── Shared Postgres ───────────────────────────────────────────────────────── # Hosts glitchtip, umami, and fider databases. postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: "${POSTGRES_USER}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" POSTGRES_DB: postgres expose: - "5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"] interval: 10s timeout: 5s retries: 5 # ── Postgres database initialisation ──────────────────────────────────────── postgres-init: image: postgres:16-alpine depends_on: postgres: condition: service_healthy environment: PGPASSWORD: "${POSTGRES_PASSWORD}" entrypoint: > /bin/sh -c " psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='fider'\" | grep -q 1 || psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE fider\"; psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='glitchtip'\" | grep -q 1 || psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE glitchtip\"; psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='umami'\" | grep -q 1 || psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE umami\"; echo 'postgres-init: databases ready'; " restart: "no" # ── GlitchTip DB migration ────────────────────────────────────────────────── glitchtip-migrate: image: glitchtip/glitchtip:latest depends_on: postgres-init: condition: service_completed_successfully postgres: condition: service_healthy environment: DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" EMAIL_URL: "${GLITCHTIP_EMAIL_URL}" DEFAULT_FROM_EMAIL: "noreply@libnovel.cc" VALKEY_URL: "redis://valkey:6379/1" command: "./manage.py migrate" restart: "no" # ── GlitchTip web ─────────────────────────────────────────────────────────── glitchtip-web: image: glitchtip/glitchtip:latest restart: unless-stopped depends_on: glitchtip-migrate: condition: service_completed_successfully expose: - "8000" environment: DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" EMAIL_URL: "${GLITCHTIP_EMAIL_URL}" DEFAULT_FROM_EMAIL: "noreply@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 ──────────────────────────────────────────────────────── glitchtip-worker: image: glitchtip/glitchtip:latest restart: unless-stopped depends_on: glitchtip-migrate: condition: service_completed_successfully environment: DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" EMAIL_URL: "${GLITCHTIP_EMAIL_URL}" DEFAULT_FROM_EMAIL: "noreply@libnovel.cc" VALKEY_URL: "redis://valkey:6379/1" SERVER_ROLE: "worker" # ── Umami ─────────────────────────────────────────────────────────────────── 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}:${POSTGRES_PASSWORD}@postgres:5432/umami" APP_SECRET: "${UMAMI_APP_SECRET}" healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:3000/api/heartbeat"] interval: 15s timeout: 5s retries: 5 # ── Fider ─────────────────────────────────────────────────────────────────── 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: "${FIDER_BASE_URL}" DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable" JWT_SECRET: "${FIDER_JWT_SECRET}" EMAIL_NOREPLY: "noreply@libnovel.cc" EMAIL_SMTP_HOST: "${FIDER_SMTP_HOST}" EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}" EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}" EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}" EMAIL_SMTP_ENABLE_STARTTLS: "false" OAUTH_GOOGLE_CLIENTID: "${OAUTH_GOOGLE_CLIENTID}" OAUTH_GOOGLE_SECRET: "${OAUTH_GOOGLE_SECRET}" OAUTH_GITHUB_CLIENTID: "${OAUTH_GITHUB_CLIENTID}" OAUTH_GITHUB_SECRET: "${OAUTH_GITHUB_SECRET}" # ── Dozzle ────────────────────────────────────────────────────────────────── # Watches both homelab and prod containers. # Prod agent runs on 165.22.70.138:7007 (added separately to prod compose). 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" DOZZLE_REMOTE_AGENT: "prod@165.22.70.138:7007" healthcheck: test: ["CMD", "/dozzle", "healthcheck"] interval: 15s timeout: 5s retries: 5 # ── Uptime Kuma ───────────────────────────────────────────────────────────── 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 ────────────────────────────────────────────────────────────────── gotify: image: gotify/server:latest restart: unless-stopped volumes: - gotify_data:/app/data expose: - "80" environment: GOTIFY_DEFAULTUSER_NAME: "${GOTIFY_ADMIN_USER}" GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS}" GOTIFY_SERVER_PORT: "80" healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:80/health"] interval: 15s timeout: 5s retries: 5 # ── Valkey ────────────────────────────────────────────────────────────────── # Used by GlitchTip for task queuing. valkey: image: valkey/valkey:7-alpine restart: unless-stopped expose: - "6379" volumes: - valkey_data:/data healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 10s timeout: 5s retries: 5 # ── OTel Collector ────────────────────────────────────────────────────────── # Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki. otel-collector: image: otel/opentelemetry-collector-contrib:latest restart: unless-stopped volumes: - ./otel/collector.yaml:/etc/otelcol-contrib/config.yaml:ro expose: - "4317" # OTLP gRPC - "4318" # OTLP HTTP - "8888" # Collector self-metrics (scraped by Prometheus) depends_on: - tempo - prometheus - loki # No healthcheck — distroless image has no shell or curl # ── Tempo ─────────────────────────────────────────────────────────────────── # Distributed trace storage. Receives OTLP from the collector. tempo: image: grafana/tempo:2.6.1 restart: unless-stopped command: ["-config.file=/etc/tempo.yaml"] volumes: - ./otel/tempo.yaml:/etc/tempo.yaml:ro - tempo_data:/var/tempo expose: - "3200" # Tempo query API (queried by Grafana) - "4317" # OTLP gRPC ingest (collector → tempo) healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3200/ready"] interval: 15s timeout: 5s retries: 5 # ── Prometheus ────────────────────────────────────────────────────────────── # Scrapes metrics from backend (via prod), runner, and otel-collector. prometheus: image: prom/prometheus:latest restart: unless-stopped command: - "--config.file=/etc/prometheus/prometheus.yaml" - "--storage.tsdb.path=/prometheus" - "--storage.tsdb.retention.time=30d" - "--web.enable-remote-write-receiver" volumes: - ./otel/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro - prometheus_data:/prometheus expose: - "9090" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"] interval: 15s timeout: 5s retries: 5 # ── Loki ──────────────────────────────────────────────────────────────────── # Log aggregation. Receives logs from OTel collector. Replaces manual Dozzle # tailing for structured log search. loki: image: grafana/loki:latest restart: unless-stopped command: ["-config.file=/etc/loki/loki.yaml"] volumes: - ./otel/loki.yaml:/etc/loki/loki.yaml:ro - loki_data:/loki expose: - "3100" # No healthcheck — distroless image has no shell or curl # ── Grafana ───────────────────────────────────────────────────────────────── # Single UI for traces (Tempo), metrics (Prometheus), and logs (Loki). # Accessible at grafana.libnovel.cc via Cloudflare Tunnel. grafana: image: grafana/grafana:latest restart: unless-stopped depends_on: - tempo - prometheus - loki expose: - "3000" volumes: - grafana_data:/var/lib/grafana - ./otel/grafana/provisioning:/etc/grafana/provisioning:ro environment: GF_SERVER_ROOT_URL: "https://grafana.libnovel.cc" GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}" GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}" GF_AUTH_ANONYMOUS_ENABLED: "false" GF_FEATURE_TOGGLES_ENABLE: "traceqlEditor" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] interval: 15s timeout: 5s retries: 5 # ── Kokoro-FastAPI (GPU TTS) ──────────────────────────────────────────────── # OpenAI-compatible TTS service backed by the Kokoro model, running on the # homelab RTX 3050 (8 GB VRAM). Replaces the broken kokoro.kalekber.cc DNS. # Voices match existing IDs: af_bella, af_sky, af_heart, etc. # The runner reaches it at http://kokoro-fastapi:8880 via the Docker network. kokoro-fastapi: image: kokoro-fastapi:latest restart: unless-stopped deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] expose: - "8880" healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8880/health"] interval: 30s timeout: 10s retries: 5 start_period: 60s # ── pocket-tts (CPU TTS) ──────────────────────────────────────────────────── # Lightweight CPU-only TTS using kyutai-labs/pocket-tts. # Image is built locally on homelab from https://github.com/kyutai-labs/pocket-tts # (no prebuilt image published): cd /tmp && git clone --depth=1 https://github.com/kyutai-labs/pocket-tts.git && docker build -t pocket-tts:latest /tmp/pocket-tts # OpenAI-compatible: POST /tts (multipart form) on port 8000. # Voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma, etc. # Not currently used by the runner (runner uses kokoro-fastapi), but available # for experimentation / fallback. pocket-tts: image: pocket-tts:latest restart: unless-stopped command: ["uv", "run", "pocket-tts", "serve", "--host", "0.0.0.0"] expose: - "8000" volumes: - pocket_tts_cache:/root/.cache/pocket_tts - hf_cache:/root/.cache/huggingface healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8000/health"] interval: 30s timeout: 10s retries: 5 start_period: 120s # ── Watchtower ────────────────────────────────────────────────────────────── # Auto-updates runner image when CI pushes a new tag. # Only watches services with the watchtower label. # doppler binary is mounted from the host so watchtower fetches fresh secrets # on every start (notification URL, credentials) without baking them in. watchtower: image: containrrr/watchtower:latest restart: unless-stopped entrypoint: ["doppler", "run", "--"] command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"] volumes: - /var/run/docker.sock:/var/run/docker.sock - /usr/local/bin/doppler:/usr/local/bin/doppler:ro environment: DOPPLER_TOKEN: "${DOPPLER_TOKEN}" DOCKER_API_VERSION: "1.44" volumes: postgres_data: valkey_data: uptime_kuma_data: gotify_data: tempo_data: prometheus_data: loki_data: grafana_data: pocket_tts_cache: hf_cache: