direction: right # ─── External ───────────────────────────────────────────────────────────────── novelfire: novelfire.net { shape: cloud style.fill: "#f0f4ff" label: "novelfire.net\n(scrape source)" } kokoro: Kokoro-FastAPI TTS { shape: cloud style.fill: "#f0f4ff" label: "Kokoro-FastAPI TTS\n(self-hosted · homelab)\nchapter audio" } pockettts: pocket-tts { shape: cloud style.fill: "#f0f4ff" label: "pocket-tts\n(self-hosted · homelab)\nvoice sample MP3s" } letsencrypt: Let's Encrypt { shape: cloud style.fill: "#f0f4ff" label: "Let's Encrypt\n(ACME TLS-ALPN-01)" } browser: Browser / iOS App { shape: person style.fill: "#fff9e6" } # ─── Init containers (one-shot) ─────────────────────────────────────────────── init: Init containers { style.fill: "#f5f5f5" style.stroke-dash: 4 minio-init: minio-init { shape: rectangle label: "minio-init\n(mc: create buckets\n chapters · audio\n avatars · catalogue)" } pb-init: pb-init { shape: rectangle label: "pb-init\n(bootstrap PocketBase\n collections + schema)" } } # ─── Storage ────────────────────────────────────────────────────────────────── storage: Storage { style.fill: "#eaf7ea" minio: MinIO { shape: cylinder label: "MinIO :9000\nbuckets:\n chapters · audio\n avatars · catalogue" } pocketbase: PocketBase { shape: cylinder label: "PocketBase :8090\ncollections:\n books · chapters_idx\n audio_cache · progress\n scrape_jobs · app_users\n ranking · library\n comments" } valkey: Valkey { shape: cylinder label: "Valkey :6379\npresign URL cache (TTL ~55 min)\nAsynq job queue (runner tasks)" } meilisearch: Meilisearch { shape: cylinder label: "Meilisearch :7700\nindex: books\n(filterable: status · genres\n sortable: rank · rating\n total_chapters · meta_updated)" } } # ─── Application — prod VPS (165.22.70.138) ─────────────────────────────────── app: Application — prod (165.22.70.138) { style.fill: "#eef3ff" caddy: caddy { shape: rectangle label: "Caddy :443 / :80 / :6380\ncustom build\n+ caddy-l4 (Redis TCP proxy)\n+ caddy-ratelimit\nauto-HTTPS · security headers\nrate limiting (per-IP)\nstatic error pages (404/502/503/504)\nCrowdSec bouncer" } backend: backend { shape: rectangle label: "Backend API :8080\n(Go)\nHTTP API server\nffmpeg (audio sample conv.)\nOpenTelemetry tracing\nSentry / GlitchTip errors" } ui: ui { shape: rectangle label: "SvelteKit UI :3000\n(adapter-node)\nSSR · session auth\nserver-side API proxy" } crowdsec: CrowdSec { shape: rectangle label: "CrowdSec :8080\nsecurity engine\nreads Caddy JSON logs\nbouncer integrated in Caddy" } dozzle: Dozzle agent { shape: rectangle label: "Dozzle agent\n127.0.0.1:7007\nlog relay → homelab dashboard" } } # ─── Runner — homelab (192.168.0.109) ──────────────────────────────────────── homelab: Runner — homelab (192.168.0.109) { style.fill: "#fef9ec" runner: runner { shape: rectangle label: "Runner :9091\n(Go background worker)\nscrape pipeline\nTTS audio job queue\nPrometheus /metrics\ncron: catalogue refresh\nAsynq worker → Valkey" } } # ─── Ops ────────────────────────────────────────────────────────────────────── ops: Ops { style.fill: "#f5f5f5" watchtower: Watchtower { shape: rectangle label: "Watchtower\n(containrrr/watchtower)\npolls Docker Hub every 5 min\nautopulls + redeploys:\n backend · ui\n(runner: label-disabled on prod)" } } # ─── CI / CD ────────────────────────────────────────────────────────────────── cicd: CI / CD { style.fill: "#f0f9ff" gitea: Gitea Actions { shape: rectangle label: "Gitea Actions\n(homelab runner)\ntag v* trigger:\n test-backend\n check-ui (type-check + build)\n docker-backend\n docker-runner\n docker-ui (bakes releases.json)\n docker-caddy\n → push Docker Hub\n → Gitea Release" } } # ─── Init → Storage ─────────────────────────────────────────────────────────── init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4} init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4} # ─── App internal ───────────────────────────────────────────────────────────── app.caddy -> app.ui: "/* (catch-all)\nSvelteKit — auth enforced" app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/catalogue\n/api/ranking /api/version\n/api/book-preview/*\n/api/chapter-text/*\n/api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/voices\n/api/audio* /api/presign/*" app.caddy -> storage.minio: "/avatars/* /audio/*\n/chapters/*\n(presigned GETs)" app.caddy -> app.crowdsec: bouncer check (15 s poll) app.caddy -> letsencrypt: ACME cert (TLS-ALPN-01) app.ui -> app.backend: "internal REST proxy\n(server-side only)" app.ui -> storage.pocketbase: "auth · sessions\nprogress · library\ncomments" app.backend -> storage.minio: "chapter objs · audio MP3s\navatars · browse cache" app.backend -> storage.pocketbase: "books · scrape_jobs\naudio_cache · ranking" app.backend -> storage.valkey: "presign URL cache\n(SET/GET TTL ~55 min)" app.backend -> storage.meilisearch: "catalogue search\nfacets: genres · status" app.backend -> pockettts: "voice sample gen.\n(on-demand · ffmpeg conv.)" # ─── Runner → deps ──────────────────────────────────────────────────────────── homelab.runner -> novelfire: "HTTP scrape\nHTML → Markdown" homelab.runner -> kokoro: "TTS generation\ntext → MP3" homelab.runner -> storage.minio: "write chapters\n& audio MP3s" homelab.runner -> storage.pocketbase: "read/update scrape_jobs\nwrite book records" homelab.runner -> storage.meilisearch: "index books\n(on scrape completion)" homelab.runner -> storage.valkey: "Asynq job queue\n(task consume)" # ─── Client ─────────────────────────────────────────────────────────────────── browser -> app.caddy: HTTPS :443\n(single entry point) # ─── Ops / CI ───────────────────────────────────────────────────────────────── ops.watchtower -> app.backend: watch (label-enabled) ops.watchtower -> app.ui: watch (label-enabled) cicd.gitea -> ops.watchtower: push to Docker Hub\n→ Watchtower detects new tag