# Homelab Expansion — Full Buildout Plan > Generated: 2026-05-04 > Based on: HOMELAB_EXPANSION_PLAN.md, ARCHITECTURE_OVERVIEW.md, STACK_STANDARDS.md This document contains everything needed to deploy all six expansion phases — docker-compose files, .env templates, directory scaffolding commands, database init SQL, Pangolin configuration, and validation steps. Each phase follows the established stack standards and deployment checklist. --- ## Table of Contents 1. [Prerequisites & Conventions](#prerequisites--conventions) 2. [Phase 1 — Gotify (Notifications)](#phase-1--gotify-notifications) 3. [Phase 2 — Qdrant (Vector Database)](#phase-2--qdrant-vector-database) 4. [Phase 3 — n8n (Workflow Automation)](#phase-3--n8n-workflow-automation) 5. [Phase 4 — Paperless-NGX (Document Intelligence)](#phase-4--paperless-ngx-document-intelligence) 6. [Phase 5 — Home Assistant (Home Automation)](#phase-5--home-assistant-home-automation) 7. [Phase 6 — Grafana + Prometheus (Observability)](#phase-6--grafana--prometheus-observability) 8. [Phase 7 — LiteLLM (AI Gateway)](#phase-7--litellm-ai-gateway) 9. [Phase 8 — Reranker (RAG Quality)](#phase-8--reranker-rag-quality) 10. [Phase 9 — faster-whisper (Speech-to-Text)](#phase-9--faster-whisper-speech-to-text) 11. [Additional Tools Setup](#additional-tools-setup) 12. [Deployment Order Summary](#deployment-order-summary) 13. [Post-Deployment Validation Master Checklist](#post-deployment-validation-master-checklist) --- ## Prerequisites & Conventions ### Deployment Standards (recap) All stacks on PlausibleDeniability follow the same pattern: - **Repo root:** `/mnt/docker-ssd/docker/compose` - **Validate before deploy:** `docker compose --env-file .env config` - **Deploy:** `docker compose --env-file .env up -d` - **Teardown:** `docker compose --env-file .env down` ### Storage Tiers | Tier | Mount | Use For | |------|-------|---------| | SSD | `/mnt/docker-ssd/docker/appdata/` | Write-heavy, SQLite, GPU/model, databases | | Tank | `/mnt/tank/docker/appdata/` | General appdata, configs, uploads | | Unraid | `/mnt/unraid/data/media/` | Media libraries only | ### Networks | Network | Created By | Purpose | |---------|-----------|---------| | `ix-databases_shared-databases` | databases stack | Access to shared-postgres, shared-mariadb, shared-redis | | `pangolin` | newt (infrastructure stack) | Reverse proxy / external exposure | ### Secret Generation Commands ```bash # Database passwords openssl rand -hex 24 # JWT / encryption keys openssl rand -hex 32 # Paperless secret key python3 -c "import secrets; print(secrets.token_urlsafe(50))" ``` ### Deployment Order ``` databases (already running) → infrastructure (already running) → Phase 1: Gotify → Phase 2: Qdrant (creates the ai-services network) → Phase 3: n8n (depends on Gotify + Qdrant — joins ai-services as external) → Phase 4: Paperless-NGX (depends on n8n for automation hooks) → Phase 5: Home Assistant (depends on n8n for heavy automation) → Phase 6: Grafana + Prometheus (on N.O.M.A.D., independent but benefits from all above) ``` > **Important:** The `ai` stack (Phase 2) must be deployed before the `automation` stack (Phase 3) because n8n declares `ai-services` as an external network. If the `ai` stack isn't up, `docker compose up` for `automation` will fail with a missing network error. --- ## Phase 1 — Gotify (Notifications) **Host:** PlausibleDeniability **Stack directory:** `/mnt/docker-ssd/docker/compose/automation/` **Why first:** Every subsequent phase sends notifications through Gotify. It's the output bus. ### 1.1 — Scaffold Directories ```bash # Appdata on SSD (SQLite backend — must not be on NFS) sudo mkdir -p /mnt/docker-ssd/docker/appdata/gotify # Stack directory (may already exist if automation/ is planned) sudo mkdir -p /mnt/docker-ssd/docker/compose/automation ``` ### 1.2 — Database Init None required — Gotify uses an embedded SQLite database stored in its data volume. ### 1.3 — docker-compose.yaml Add to `/mnt/docker-ssd/docker/compose/automation/docker-compose.yaml`: ```yaml name: automation services: gotify: image: gotify/server:2.6.1 container_name: gotify restart: unless-stopped ports: - "8484:80" environment: TZ: ${TZ} GOTIFY_DEFAULTUSER_PASS: ${GOTIFY_ADMIN_PASS} GOTIFY_SERVER_PORT: 80 GOTIFY_DATABASE_DIALECT: sqlite3 GOTIFY_DATABASE_CONNECTION: data/gotify.db volumes: - /mnt/docker-ssd/docker/appdata/gotify:/app/data networks: - pangolin - default networks: pangolin: external: true ``` > **Image note:** `gotify/server:2.6.1` is the latest stable as of May 2026. Check [Docker Hub](https://hub.docker.com/r/gotify/server/tags) before deploying — pin to the exact version. ### 1.4 — .env.example ```bash # /mnt/docker-ssd/docker/compose/automation/.env.example TZ=America/New_York GOTIFY_ADMIN_PASS=CHANGE_ME ``` ### 1.5 — .env (create from example) ```bash cd /mnt/docker-ssd/docker/compose/automation cp .env.example .env # Edit .env with real values: # GOTIFY_ADMIN_PASS=$(openssl rand -hex 16) nano .env ``` ### 1.6 — Pangolin Configuration In your Pangolin dashboard, create a new resource: | Field | Value | |-------|-------| | Domain | `gotify.paccoco.com` | | Scheme | `http` | | Host | `gotify` | | Port | `80` | | Network | Docker service name resolution via `pangolin` network | ### 1.7 — Validate & Deploy ```bash cd /mnt/docker-ssd/docker/compose/automation docker compose --env-file .env config docker compose --env-file .env up -d ``` ### 1.8 — Post-Deploy Verification ```bash # Container running? docker ps --filter name=gotify # Clean startup logs? docker logs gotify --tail 20 # Mounts correct? docker inspect gotify --format '{{json .Mounts}}' | python3 -m json.tool # Quick health check curl -s http://localhost:8484/health # Test notification via API curl -s "http://localhost:8484/message?token=YOUR_APP_TOKEN" \ -F "title=Homelab" \ -F "message=Gotify is online" \ -F "priority=5" ``` ### 1.9 — First-Run Setup 1. Navigate to `https://gotify.paccoco.com` 2. Log in with the admin password from `.env` 3. **Change the default admin password** in the UI 4. Create application tokens for each notification source: - `n8n-workflows` — for all n8n automations - `infrastructure-alerts` — for Uptime Kuma, Grafana, etc. - `media-notifications` — for Sonarr/Radarr/Tautulli hooks - `home-assistant` — for HA automations 5. Create client tokens for each receiving device (phone, desktop) 6. Install the Gotify Android app and configure with your client token --- ## Phase 2 — AI Stack (Vector DB, LLM, Embeddings, Reranker, STT) **Host:** PlausibleDeniability **Stack directory:** `/mnt/docker-ssd/docker/compose/ai/` **Status:** DEPLOYED AND VERIFIED (2026-05-05) > **DEPLOYED NOTE:** This phase was deployed as a complete AI stack with 6 services: Ollama (11434), OpenWebUI (8282), Qdrant (6333/6334), LiteLLM (4000), Reranker/TEI (8787), and faster-whisper (8786). The compose below shows the original Qdrant-only plan — see the actual running compose on PD at `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml` and the `project_ai_stack_deployed.md` memory for port mappings and fixes. Key lesson: always use `10.5.1.6` not `localhost` for health checks on TrueNAS Scale. ### 2.1 — Scaffold Directories ```bash # Appdata on SSD (write-heavy vector storage) sudo mkdir -p /mnt/docker-ssd/docker/appdata/qdrant/storage sudo mkdir -p /mnt/docker-ssd/docker/appdata/qdrant/snapshots # Stack directory sudo mkdir -p /mnt/docker-ssd/docker/compose/ai ``` ### 2.2 — docker-compose.yaml Create or update `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`: ```yaml name: ai services: qdrant: image: qdrant/qdrant:v1.14.0 container_name: qdrant restart: unless-stopped ports: - "6333:6333" # REST API - "6334:6334" # gRPC environment: TZ: ${TZ} QDRANT__SERVICE__GRPC_PORT: 6334 QDRANT__STORAGE__STORAGE_PATH: /qdrant/storage QDRANT__STORAGE__SNAPSHOTS_PATH: /qdrant/snapshots volumes: - /mnt/docker-ssd/docker/appdata/qdrant/storage:/qdrant/storage - /mnt/docker-ssd/docker/appdata/qdrant/snapshots:/qdrant/snapshots networks: - ai-services - default # ------------------------------------------------------- # Ollama and OpenWebUI go here when deployed. # They share this stack and the default + ai-services networks so # OpenWebUI can reach Qdrant at http://qdrant:6333 # ------------------------------------------------------- # ollama: # image: ollama/ollama:latest # container_name: ollama # ... # openwebui: # image: ghcr.io/open-webui/open-webui:main # container_name: openwebui # ... networks: ai-services: name: ai-services ``` > **Image note:** `qdrant/qdrant:v1.14.0` is the latest stable as of May 2026. Check [GitHub releases](https://github.com/qdrant/qdrant/releases) before deploying. > **Cross-stack connectivity:** The `ai-services` network is defined here with an explicit `name:` so other stacks (like `automation/n8n`) can declare it as `external: true` and reach Qdrant by service name (`http://qdrant:6333`). This follows the same pattern as `ix-databases_shared-databases`. ### 2.3 — .env.example ```bash # /mnt/docker-ssd/docker/compose/ai/.env.example TZ=America/New_York ``` ### 2.4 — Validate & Deploy ```bash cd /mnt/docker-ssd/docker/compose/ai cp .env.example .env nano .env # Set timezone docker compose --env-file .env config docker compose --env-file .env up -d ``` ### 2.5 — Post-Deploy Verification ```bash # Container running? docker ps --filter name=qdrant # Clean startup? docker logs qdrant --tail 20 # REST API responding? curl -s http://localhost:6333/healthz # Expected: {"title":"qdrant - vectorass engine","version":"1.14.0","commit":"..."} # Create a test collection curl -X PUT http://localhost:6333/collections/test_collection \ -H "Content-Type: application/json" \ -d '{ "vectors": { "size": 384, "distance": "Cosine" } }' # Verify it exists curl -s http://localhost:6333/collections | python3 -m json.tool # Clean up test curl -X DELETE http://localhost:6333/collections/test_collection ``` ### 2.6 — OpenWebUI Integration (when deployed) When Ollama and OpenWebUI are brought online in this same stack, configure OpenWebUI's RAG settings: 1. Go to OpenWebUI → Admin → Settings → Documents 2. Set the vector database to Qdrant 3. Endpoint: `http://qdrant:6333` (Docker DNS within the `ai` stack network) 4. Collection name: `openwebui_docs` (or your preference) ### 2.7 — Collections to Create (for n8n in Phase 3) These collections will be created programmatically by n8n workflows, but for reference: | Collection | Vector Size | Content | |-----------|-------------|---------| | `homelab_docs` | 384 (nomic-embed-text) | Homelab markdown documentation | | `gitea_commits` | 384 | Gitea commit messages + diffs | | `media_metadata` | 384 | Plex/Tautulli metadata | | `obsidian_notes` | 384 | Personal notes from Obsidian vault | > **Vector size note:** 384 is the dimension for `nomic-embed-text` via Ollama. If you use a different embedding model, adjust accordingly. --- ## Phase 3 — n8n (Workflow Automation) **Host:** PlausibleDeniability **Stack directory:** `/mnt/docker-ssd/docker/compose/automation/` (same stack as Gotify) **Why third:** n8n is the orchestration backbone — it ties Gotify, Qdrant, Ollama, and all triggers together. ### 3.1 — Scaffold Directories ```bash # Config on tank (workflow definitions, credentials store) # NOTE: n8n also writes execution logs here. If execution logging becomes # heavy (many workflows running frequently), consider moving to SSD. # For typical homelab usage (~20 workflows), tank is fine. sudo mkdir -p /mnt/tank/docker/appdata/n8n # Set ownership — n8n runs as UID 1000 (node user) sudo chown 1000:1000 /mnt/tank/docker/appdata/n8n ``` ### 3.2 — Database Init n8n uses the existing shared-postgres. Create its database and user: ```bash docker exec -i shared-postgres psql -U postgres <<'SQL' CREATE USER n8n WITH PASSWORD 'REPLACE_WITH_GENERATED_PASSWORD'; CREATE DATABASE n8n OWNER n8n; GRANT ALL PRIVILEGES ON DATABASE n8n TO n8n; SQL ``` Generate the password first: ```bash openssl rand -hex 24 ``` ### 3.3 — docker-compose.yaml Update `/mnt/docker-ssd/docker/compose/automation/docker-compose.yaml` to add n8n alongside Gotify: ```yaml name: automation services: gotify: image: gotify/server:2.6.1 container_name: gotify restart: unless-stopped ports: - "8484:80" environment: TZ: ${TZ} GOTIFY_DEFAULTUSER_PASS: ${GOTIFY_ADMIN_PASS} GOTIFY_SERVER_PORT: 80 GOTIFY_DATABASE_DIALECT: sqlite3 GOTIFY_DATABASE_CONNECTION: data/gotify.db volumes: - /mnt/docker-ssd/docker/appdata/gotify:/app/data networks: - pangolin - default n8n: image: docker.n8n.io/n8nio/n8n:1.88.0 container_name: n8n restart: unless-stopped ports: - "5678:5678" environment: TZ: ${TZ} # Database DB_TYPE: postgresdb DB_POSTGRESDB_HOST: shared-postgres DB_POSTGRESDB_PORT: 5432 DB_POSTGRESDB_DATABASE: ${N8N_DB_NAME} DB_POSTGRESDB_USER: ${N8N_DB_USER} DB_POSTGRESDB_PASSWORD: ${N8N_DB_PASS} # Security N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY} # Webhook / Reverse Proxy N8N_HOST: ${N8N_HOST} N8N_PROTOCOL: https N8N_PORT: 5678 WEBHOOK_URL: https://${N8N_HOST}/ N8N_PROXY_HOPS: 1 # General GENERIC_TIMEZONE: ${TZ} N8N_DIAGNOSTICS_ENABLED: false N8N_PERSONALIZATION_ENABLED: false volumes: - /mnt/tank/docker/appdata/n8n:/home/node/.n8n networks: - pangolin - ix-databases_shared-databases - ai-services - default depends_on: - gotify networks: pangolin: external: true ix-databases_shared-databases: external: true ai-services: external: true ``` > **Image note:** `docker.n8n.io/n8nio/n8n:1.88.0` — n8n uses calendar-ish versioning. Check [n8n releases](https://github.com/n8n-io/n8n/releases) for the latest stable. The `docker.n8n.io` registry is preferred for production. > **Cross-stack access:** n8n joins `ai-services` (created by the `ai` stack) so it can reach Qdrant at `http://qdrant:6333` by Docker DNS. It also joins `ix-databases_shared-databases` for Postgres access — same pattern. ### 3.4 — .env.example (updated for both services) ```bash # /mnt/docker-ssd/docker/compose/automation/.env.example TZ=America/New_York # Gotify GOTIFY_ADMIN_PASS=CHANGE_ME # n8n — Database N8N_DB_NAME=n8n N8N_DB_USER=n8n N8N_DB_PASS=CHANGE_ME # n8n — Security N8N_ENCRYPTION_KEY=CHANGE_ME # n8n — Hostname N8N_HOST=n8n.paccoco.com ``` ### 3.5 — Pangolin Configuration | Field | Value | |-------|-------| | Domain | `n8n.paccoco.com` | | Scheme | `http` | | Host | `n8n` | | Port | `5678` | | Headers | Forward `X-Forwarded-Proto: https` | ### 3.6 — Validate & Deploy ```bash cd /mnt/docker-ssd/docker/compose/automation # Update .env with real passwords nano .env docker compose --env-file .env config docker compose --env-file .env up -d ``` ### 3.7 — Post-Deploy Verification ```bash # Both containers running? docker ps --filter name=gotify --filter name=n8n # n8n logs clean? docker logs n8n --tail 30 # n8n on correct networks? docker inspect n8n --format '{{json .NetworkSettings.Networks}}' | python3 -m json.tool # n8n UI accessible? curl -s -o /dev/null -w "%{http_code}" http://localhost:5678/ # Expected: 200 # Postgres connection working? (check logs for DB migration messages) docker logs n8n 2>&1 | grep -i "migrat" ``` ### 3.8 — First-Run Setup 1. Navigate to `https://n8n.paccoco.com` 2. Create your admin account 3. Install community nodes you'll need: - `n8n-nodes-gotify` (if available) or use HTTP Request node - Ollama nodes (built-in as of n8n 1.x) ### 3.9 — Workflow Blueprints Below are starter workflow descriptions for each planned automation. These are meant to be built in the n8n UI — the structure and node types are described so you can wire them up. #### Media Pipeline Workflows **Workflow: Sonarr/Radarr Download Notification** ``` Trigger: Webhook node (POST from Sonarr/Radarr on download/import) Step 1: Extract series/movie name, quality, file path from webhook body Step 2: HTTP Request → TMDB API to fetch poster image URL Step 3: HTTP Request → Gotify REST API (POST /message) - Title: "New Download: {title}" - Message: "{quality} — {episodeTitle or year}" - Priority: 5 - Extras: attach poster URL as markdown image Also: HTTP Request → Discord webhook (formatted embed with poster) ``` Configure Sonarr/Radarr webhooks: - Sonarr: Settings → Connect → Webhook → URL: `https://n8n.paccoco.com/webhook/sonarr` - Radarr: Settings → Connect → Webhook → URL: `https://n8n.paccoco.com/webhook/radarr` **Workflow: Tautulli Play Logging** ``` Trigger: Webhook node (POST from Tautulli on playback start) Step 1: Extract user, media title, player, quality from payload Step 2: Postgres node → INSERT into watch_history table Step 3: (Optional) Gotify notification for specific users/media ``` Tautulli config: Settings → Notification Agents → Webhook → URL: `https://n8n.paccoco.com/webhook/tautulli-play` **Workflow: Weekly Watch Digest** ``` Trigger: Cron node (every Sunday at 10:00 AM) Step 1: Postgres node → SELECT watch history for past 7 days Step 2: Format data as structured text Step 3: HTTP Request → Ollama API (POST to PD's qwen2.5:14b) - Prompt: "Summarize this week's viewing in a fun digest: {data}" Step 4: HTTP Request → Gotify (send digest) ``` #### Infrastructure Monitoring Workflows **Workflow: Uptime Kuma Enhanced Alerts** ``` Trigger: Webhook node (from Uptime Kuma notification) Step 1: Extract monitor name, status, response time Step 2: HTTP Request → Netdata API for related metrics context Step 3: HTTP Request → Gotify - Title: "🔴 {monitor} DOWN" or "🟢 {monitor} UP" - Message: include Netdata context (CPU, mem, disk at time of alert) - Priority: 8 (high for down, 3 for recovery) ``` **Workflow: ZFS Pool Utilization Alert** ``` Trigger: Cron node (every 6 hours) Step 1: SSH node → Serenity: `zpool list -Hp malcolm` Step 2: Parse capacity percentage Step 3: IF capacity > 85% → Gotify alert (priority 8) Step 4: IF capacity > 90% → Gotify alert (priority 10) + Discord webhook ``` **Workflow: Grafana Alert Remediation** ``` Trigger: Webhook node (from Grafana alerting) Step 1: Parse alert labels (container, host, metric) Step 2: Switch node → route by alert type: - High CPU container → SSH → docker restart {container} - Disk full → SSH → pause qBittorrent, notify via Gotify - Memory pressure → Gotify alert only (manual intervention) Step 3: Log action taken to Postgres ``` #### Homelab Ops Workflows **Workflow: Gitea Commit Summary** ``` Trigger: Webhook node (Gitea webhook on push to truenas-stacks) Step 1: Extract commit messages, author, files changed Step 2: HTTP Request → Ollama API - Prompt: "Summarize this commit in one sentence: {commit_message}" Step 3: HTTP Request → Gotify - Title: "Commit to truenas-stacks" - Message: Ollama-generated summary ``` Gitea config: Repository → Settings → Webhooks → URL: `https://n8n.paccoco.com/webhook/gitea-push` **Workflow: qBittorrent Auto-Rescan** ``` Trigger: Webhook or polling (qBittorrent API for completed+moved torrents) Step 1: Determine if file is in Sonarr or Radarr path Step 2: HTTP Request → Sonarr API (POST /command → RescanSeries) OR HTTP Request → Radarr API (POST /command → RescanMovie) Step 3: Gotify notification confirming rescan triggered ``` #### AI Pipeline Workflows **Workflow: URL Digest Pipeline** ``` Trigger: Webhook node (POST with URL in body) Step 1: HTTP Request → fetch page content Step 2: Code node → extract text, chunk into ~500 token segments Step 3: HTTP Request → Ollama API → summarize each chunk Step 4: Code node → combine summaries into digest Step 5: Postgres node → store digest with metadata Step 6: Return digest in webhook response ``` **Workflow: Multi-Model Query Router (simplified by LiteLLM — see Phase 7)** ``` Trigger: Webhook node (POST with query + complexity hint) Step 1: HTTP Request → LiteLLM (POST http://litellm:4000/v1/chat/completions) - model: complexity parameter ("light", "medium", or "heavy") - LiteLLM handles routing to the correct Ollama instance Step 2: Return response via webhook ``` > With LiteLLM deployed, the Switch node and three separate Ollama endpoints > are replaced by a single HTTP Request node. The routing logic lives in > LiteLLM's config.yaml instead of n8n workflow logic. **Workflow: Qdrant Index Updater** ``` Trigger: Webhook node (from Gitea push to any watched repo) Step 1: HTTP Request → Gitea API → fetch changed files content Step 2: Code node → chunk text into embedding-sized segments Step 3: HTTP Request → Ollama API (embed endpoint with nomic-embed-text) Step 4: HTTP Request → Qdrant API (PUT /collections/homelab_docs/points) Step 5: Gotify notification: "{n} documents re-indexed" ``` #### Home / Business Workflows **Workflow: KitchenOwl Grocery Notification** ``` Trigger: Polling (KitchenOwl API) or webhook if supported Step 1: Fetch current shopping list items Step 2: Format as clean text list Step 3: HTTP Request → Gotify → phone push notification ``` **Workflow: Donetick Task Reminder** ``` Trigger: Cron node (daily at 9:00 AM) Step 1: HTTP Request → Donetick API → fetch tasks due today/overdue Step 2: Format task list Step 3: HTTP Request → Gotify (priority 5) ``` **Workflow: Long and Low Crafts Order Pipeline** ``` Trigger: Webhook (Etsy webhook or email trigger via IMAP node) Step 1: Parse order details (item, quantity, customer, shipping) Step 2: HTTP Request → Donetick API → create fulfillment task Step 3: HTTP Request → Gotify DM notification - Title: "New L&L Order" - Message: "{item} x{qty} — ship by {date}" - Priority: 7 ``` --- ## Phase 4 — Paperless-NGX (Document Intelligence) **Host:** PlausibleDeniability **Stack directory:** `/mnt/docker-ssd/docker/compose/documents/` (new stack) **Why fourth:** Depends on n8n for automation hooks and Gotify for notifications. ### 4.1 — Scaffold Directories ```bash # Index/data on SSD (search index is write-heavy) sudo mkdir -p /mnt/docker-ssd/docker/appdata/paperless/data # Documents (media) on tank (bulk storage, read-heavy) sudo mkdir -p /mnt/tank/docker/appdata/paperless/media # Consume folder on tank (drop zone for new documents) sudo mkdir -p /mnt/tank/docker/appdata/paperless/consume # Export folder on tank sudo mkdir -p /mnt/tank/docker/appdata/paperless/export # Stack directory sudo mkdir -p /mnt/docker-ssd/docker/compose/documents ``` ### 4.2 — Database Init Paperless uses the existing shared-postgres and shared-redis: ```bash docker exec -i shared-postgres psql -U postgres <<'SQL' CREATE USER paperless WITH PASSWORD 'REPLACE_WITH_GENERATED_PASSWORD'; CREATE DATABASE paperless OWNER paperless; GRANT ALL PRIVILEGES ON DATABASE paperless TO paperless; SQL ``` ### 4.3 — docker-compose.yaml Create `/mnt/docker-ssd/docker/compose/documents/docker-compose.yaml`: ```yaml name: documents services: paperless: image: ghcr.io/paperless-ngx/paperless-ngx:2.16 container_name: paperless restart: unless-stopped ports: - "8000:8000" environment: TZ: ${TZ} # Database PAPERLESS_DBENGINE: postgresql PAPERLESS_DBHOST: shared-postgres PAPERLESS_DBPORT: 5432 PAPERLESS_DBNAME: ${PAPERLESS_DB_NAME} PAPERLESS_DBUSER: ${PAPERLESS_DB_USER} PAPERLESS_DBPASS: ${PAPERLESS_DB_PASS} # Redis (using shared-redis) PAPERLESS_REDIS: redis://shared-redis:6379 # Security PAPERLESS_SECRET_KEY: ${PAPERLESS_SECRET_KEY} PAPERLESS_URL: https://${PAPERLESS_HOST} PAPERLESS_ADMIN_USER: ${PAPERLESS_ADMIN_USER} PAPERLESS_ADMIN_PASSWORD: ${PAPERLESS_ADMIN_PASS} # OCR PAPERLESS_OCR_LANGUAGE: eng PAPERLESS_OCR_MODE: skip # Tika/Gotenberg for Office documents PAPERLESS_TIKA_ENABLED: 1 PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 # Performance PAPERLESS_TASK_WORKERS: 2 PAPERLESS_THREADS_PER_WORKER: 2 volumes: - /mnt/docker-ssd/docker/appdata/paperless/data:/usr/src/paperless/data - /mnt/tank/docker/appdata/paperless/media:/usr/src/paperless/media - /mnt/tank/docker/appdata/paperless/consume:/usr/src/paperless/consume - /mnt/tank/docker/appdata/paperless/export:/usr/src/paperless/export networks: - pangolin - ix-databases_shared-databases - default depends_on: - gotenberg - tika gotenberg: image: gotenberg/gotenberg:8.17 container_name: paperless-gotenberg restart: unless-stopped command: - "gotenberg" - "--chromium-disable-javascript=true" - "--chromium-allow-list=file:///tmp/.*" networks: - default tika: image: apache/tika:3.1 container_name: paperless-tika restart: unless-stopped networks: - default networks: pangolin: external: true ix-databases_shared-databases: external: true ``` > **Image notes:** > - `ghcr.io/paperless-ngx/paperless-ngx:2.16` — check [releases](https://github.com/paperless-ngx/paperless-ngx/releases) for latest. > - `gotenberg/gotenberg:8.17` — pin to a specific 8.x tag. > - `apache/tika:3.1` — needed for Office doc support (.docx, .xlsx, .odt). > - Gotenberg and Tika are only needed if you ingest Office documents. For PDF-only, you can omit them and set `PAPERLESS_TIKA_ENABLED: 0`. ### 4.4 — .env.example ```bash # /mnt/docker-ssd/docker/compose/documents/.env.example TZ=America/New_York # Paperless — Database PAPERLESS_DB_NAME=paperless PAPERLESS_DB_USER=paperless PAPERLESS_DB_PASS=CHANGE_ME # Paperless — Security PAPERLESS_SECRET_KEY=CHANGE_ME PAPERLESS_ADMIN_USER=admin PAPERLESS_ADMIN_PASS=CHANGE_ME # Paperless — Hostname PAPERLESS_HOST=paperless.paccoco.com ``` ### 4.5 — Pangolin Configuration | Field | Value | |-------|-------| | Domain | `paperless.paccoco.com` | | Scheme | `http` | | Host | `paperless` | | Port | `8000` | ### 4.6 — Validate & Deploy ```bash cd /mnt/docker-ssd/docker/compose/documents cp .env.example .env nano .env # Fill in real values docker compose --env-file .env config docker compose --env-file .env up -d ``` ### 4.7 — Post-Deploy Verification ```bash # All three containers running? docker ps --filter name=paperless --filter name=paperless-gotenberg --filter name=paperless-tika # Paperless logs clean? (watch for DB migration output) docker logs paperless --tail 30 # Network connectivity to shared-postgres and shared-redis? docker exec paperless python3 -c "import psycopg2; print('postgres OK')" 2>/dev/null || echo "Check DB connection" # UI accessible? curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/ # Expected: 200 or 302 (redirect to login) ``` ### 4.8 — n8n Integration Workflows **Workflow: Email Attachment → Paperless** ``` Trigger: IMAP node (poll email inbox every 5 minutes) Step 1: Filter for emails with PDF/document attachments Step 2: Download attachment binary Step 3: HTTP Request → Paperless API (POST /api/documents/post_document/) - Multipart form with document file - Optional: set correspondent, document type, tags Step 4: Wait node (30 seconds for OCR processing) Step 5: HTTP Request → Paperless API (GET document details) Step 6: IF tagged as "action-required" → HTTP Request → Donetick API → create task Step 7: HTTP Request → Gotify → "New document ingested: {title}" ``` **Workflow: Ollama Document Summarizer** ``` Trigger: Paperless webhook (on document consumed) or n8n polling Step 1: HTTP Request → Paperless API → fetch document text Step 2: HTTP Request → Ollama API (PD's qwen2.5:14b) - Prompt: "Extract key data from this document: dates, amounts, parties, action items. {text}" Step 3: HTTP Request → Paperless API → update document notes with summary Step 4: HTTP Request → Gotify → "Document summarized: {title}" ``` --- ## Phase 5 — Home Assistant (Home Automation) **Host:** PlausibleDeniability (preferred — same network as most services) **Stack directory:** `/mnt/docker-ssd/docker/compose/homeassistant/` (standalone stack — host networking isolates it) **Why fifth:** Benefits from n8n being operational. n8n handles complex automation logic while HA handles device control. ### 5.1 — Scaffold Directories ```bash # Config on SSD (SQLite database, write-heavy) sudo mkdir -p /mnt/docker-ssd/docker/appdata/homeassistant ``` ### 5.2 — docker-compose.yaml Create `/mnt/docker-ssd/docker/compose/homeassistant/docker-compose.yaml`: ```yaml name: homeassistant services: homeassistant: image: ghcr.io/home-assistant/home-assistant:2026.5 container_name: homeassistant restart: unless-stopped privileged: true network_mode: host environment: TZ: ${TZ} volumes: - /mnt/docker-ssd/docker/appdata/homeassistant:/config - /etc/localtime:/etc/localtime:ro - /run/dbus:/run/dbus:ro # devices: # - /dev/ttyUSB0:/dev/ttyUSB0 # Uncomment for USB Zigbee/Z-Wave sticks ``` > **Image note:** `ghcr.io/home-assistant/home-assistant:2026.5` — use the latest stable monthly release. Check [HA releases](https://www.home-assistant.io/blog/categories/release-notes/). Pin to a specific minor like `2026.5.1` once you confirm it's stable. > **Why host networking:** Home Assistant requires `network_mode: host` for mDNS/SSDP device discovery. This means it does NOT join `pangolin` — you'll access it directly by IP and port, or configure Pangolin to proxy to `http://10.5.1.X:8123`. > **Why standalone stack:** Host networking is incompatible with other services in the same compose that use bridge networking. HA must be in its own compose file. ### 5.3 — .env.example ```bash # /mnt/docker-ssd/docker/compose/homeassistant/.env.example TZ=America/New_York ``` ### 5.4 — Pangolin Configuration Since HA uses host networking, Pangolin needs to reach it by the host's IP: | Field | Value | |-------|-------| | Domain | `ha.paccoco.com` | | Scheme | `http` | | Host | `10.5.1.X` (PlausibleDeniability's LAN IP) | | Port | `8123` | > Find PD's IP with: `hostname -I | awk '{print $1}'` ### 5.5 — Validate & Deploy ```bash cd /mnt/docker-ssd/docker/compose/homeassistant cp .env.example .env nano .env docker compose --env-file .env config docker compose --env-file .env up -d ``` ### 5.6 — Post-Deploy Verification ```bash # Container running? docker ps --filter name=homeassistant # Clean startup? docker logs homeassistant --tail 30 # Web UI accessible? curl -s -o /dev/null -w "%{http_code}" http://localhost:8123/ # Expected: 200 ``` ### 5.7 — First-Run Setup & Integrations 1. Navigate to `http://PD_IP:8123` (or `https://ha.paccoco.com`) 2. Complete the onboarding wizard 3. Add integrations: **Smart Plug Power Monitoring:** - Install TP-Link Kasa / Tapo / Shelly integration (depends on your plug brand) - Add plugs for Serenity, PD, N.O.M.A.D. - Create energy dashboard for power consumption tracking **UPS Monitoring:** - Install NUT (Network UPS Tools) integration if you add a UPS - Note: PD and N.O.M.A.D. currently have no UPS — this is a known gap - When UPS is added, create automations for graceful shutdown via n8n **Presence Detection:** - Mobile app integration for phone-based presence - Or use router/UniFi integration for network-based detection ### 5.8 — n8n ↔ Home Assistant Integration In n8n, use the Home Assistant nodes (built-in): 1. In HA: Profile → Long-Lived Access Tokens → Create Token 2. In n8n: Settings → Credentials → Home Assistant API - Host: `http://10.5.1.X:8123` - Access Token: (paste from step 1) **Workflow: Presence → Server Sleep/Wake** ``` Trigger: HA event node (person.fizzlepoof state change) Step 1: IF state = "not_home" for > 30 min AND no active Plex streams: SSH → N.O.M.A.D.: systemctl suspend Step 2: IF state = "home": Wake-on-LAN → N.O.M.A.D. MAC address Step 3: Gotify notification: "Server {action}: N.O.M.A.D." ``` **Workflow: Physical Device → n8n Trigger** ``` Trigger: HA webhook or event node (smart plug power draw spike) Step 1: IF washing machine plug power < 5W for 5 min after being > 100W: Gotify notification: "Laundry is done!" ``` --- ## Phase 6 — Grafana + Prometheus (Observability) **Host:** N.O.M.A.D. (Ubuntu 25.10 — dedicated HDDs have headroom) **Stack directory:** `/opt/monitoring/` (outside the N.O.M.A.D. project directory) **Why last:** Observability benefits from all other services being online — more to monitor. ### 6.1 — Scaffold Directories SSH into N.O.M.A.D.: ```bash ssh nomad@10.5.1.16 # Stack directory sudo mkdir -p /opt/monitoring # Prometheus data on hdd-2 (has more headroom) sudo mkdir -p /mnt/hdd-2/prometheus-data sudo chown 65534:65534 /mnt/hdd-2/prometheus-data # nobody user (Prometheus default) # Grafana data on hdd-2 sudo mkdir -p /mnt/hdd-2/grafana-data sudo chown 472:472 /mnt/hdd-2/grafana-data # grafana user # Config directories sudo mkdir -p /opt/monitoring/provisioning/datasources sudo mkdir -p /opt/monitoring/provisioning/dashboards ``` ### 6.2 — prometheus.yml Create `/opt/monitoring/prometheus.yml`: ```yaml global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: # ------- Local (N.O.M.A.D.) ------- - job_name: 'nomad-node' static_configs: - targets: ['node-exporter:9100'] labels: host: 'nomad' # ------- PlausibleDeniability ------- - job_name: 'pd-netdata' metrics_path: /api/v1/allmetrics params: format: [prometheus] static_configs: - targets: ['10.5.1.X:19999'] # Replace with PD's IP labels: host: 'plausible-deniability' # ------- Serenity ------- - job_name: 'serenity-netdata' metrics_path: /api/v1/allmetrics params: format: [prometheus] static_configs: - targets: ['10.5.1.5:19999'] labels: host: 'serenity' # ------- Gotify ------- # Gotify exposes /health but no Prometheus endpoint natively. # Use blackbox exporter or just rely on Uptime Kuma. # ------- n8n ------- # n8n doesn't expose Prometheus metrics by default. # Monitor via container resource metrics from Netdata. # -------- Add more targets as needed -------- # When node-exporter is installed on PD and Serenity: # - job_name: 'pd-node' # static_configs: # - targets: ['10.5.1.X:9100'] # labels: # host: 'plausible-deniability' # # - job_name: 'serenity-node' # static_configs: # - targets: ['10.5.1.5:9100'] # labels: # host: 'serenity' ``` > **Netdata as a Prometheus target:** Both PD and Serenity already run Netdata. Netdata has a built-in Prometheus exporter at `/api/v1/allmetrics?format=prometheus`. This gives you CPU, memory, disk, network, and ZFS metrics without installing node-exporter on those hosts. ### 6.3 — Grafana Provisioning Create `/opt/monitoring/provisioning/datasources/prometheus.yml`: ```yaml apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true editable: true ``` Create `/opt/monitoring/provisioning/dashboards/dashboards.yml`: ```yaml apiVersion: 1 providers: - name: 'default' orgId: 1 folder: '' type: file disableDeletion: false editable: true options: path: /var/lib/grafana/dashboards foldersFromFilesStructure: false ``` ### 6.4 — docker-compose.yaml Create `/opt/monitoring/docker-compose.yaml`: ```yaml name: monitoring services: prometheus: image: prom/prometheus:v3.4.0 container_name: prometheus restart: unless-stopped ports: - "9090:9090" command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=90d' - '--web.enable-lifecycle' volumes: - /opt/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - /mnt/hdd-2/prometheus-data:/prometheus networks: - monitoring grafana: image: grafana/grafana:13.0.1 container_name: grafana restart: unless-stopped ports: - "3000:3000" environment: TZ: ${TZ} GF_SECURITY_ADMIN_USER: ${GF_ADMIN_USER} GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASS} GF_SERVER_ROOT_URL: https://${GF_HOST}/ volumes: - /mnt/hdd-2/grafana-data:/var/lib/grafana - /opt/monitoring/provisioning:/etc/grafana/provisioning:ro networks: - monitoring node-exporter: image: prom/node-exporter:v1.9.0 container_name: node-exporter restart: unless-stopped ports: - "9100:9100" command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' - '--path.rootfs=/rootfs' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro - /:/rootfs:ro networks: - monitoring networks: monitoring: driver: bridge ``` > **Image notes:** > - `prom/prometheus:v3.4.0` — Prometheus v3 is the current major line. **Do not use `:latest`** — it still resolves to 2.x due to a known tagging issue. Always use an explicit `v3.x.y` tag. > - `grafana/grafana:latest` — use the moving stable image by default here; only pin Grafana if a breakage or compatibility reason is documented. > - `prom/node-exporter:v1.9.0` — check [releases](https://github.com/prometheus/node_exporter/releases). ### 6.5 — .env.example ```bash # /opt/monitoring/.env.example TZ=America/New_York # Grafana GF_ADMIN_USER=admin GF_ADMIN_PASS=CHANGE_ME GF_HOST=grafana.paccoco.com ``` ### 6.6 — Pangolin Configuration Grafana runs on N.O.M.A.D., not PD where the main Newt agent lives. You have two options: **Option A — Route via N.O.M.A.D.'s existing Newt (recommended if already connected)** N.O.M.A.D. already has a Newt container from the Project N.O.M.A.D. setup. If it's connected to your Pangolin VPS, just add a new resource in the Pangolin dashboard pointing to `http://grafana:3000` or `http://10.5.1.16:3000`. **Option B — Add a dedicated Newt to the monitoring stack** If N.O.M.A.D.'s existing Newt is not connected to Pangolin (or is a separate Pangolin instance), add Newt to the monitoring compose: ```yaml newt: image: ghcr.io/fosrl/newt:latest container_name: monitoring-newt restart: unless-stopped environment: PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT} NEWT_ID: ${NEWT_ID} NEWT_SECRET: ${NEWT_SECRET} networks: - monitoring ``` Then in Pangolin dashboard: | Field | Value | |-------|-------| | Domain | `grafana.paccoco.com` | | Scheme | `http` | | Host | `grafana` (Docker DNS via shared network) | | Port | `3000` | **Option C — Direct IP routing (simplest, no Newt needed)** If Pangolin's Newt on PD can reach N.O.M.A.D. by LAN IP (they're on the same subnet): | Field | Value | |-------|-------| | Domain | `grafana.paccoco.com` | | Scheme | `http` | | Host | `10.5.1.16` | | Port | `3000` | > **Decision point:** Check if N.O.M.A.D.'s existing Newt is connected to your Pangolin instance before deploying. Run `docker ps --filter name=newt` on N.O.M.A.D. to verify. If it's running and connected, Option A is zero-effort. If not, Option C is the simplest fallback. ### 6.7 — Validate & Deploy ```bash ssh nomad@10.5.1.16 cd /opt/monitoring cp .env.example .env nano .env # Fill in real values docker compose --env-file .env config docker compose --env-file .env up -d ``` ### 6.8 — Post-Deploy Verification ```bash # All three containers running? docker ps --filter name=prometheus --filter name=grafana --filter name=node-exporter # Prometheus scraping targets? curl -s http://localhost:9090/api/v1/targets | python3 -m json.tool | head -40 # Grafana UI accessible? curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/ # Expected: 200 or 302 # Node exporter metrics flowing? curl -s http://localhost:9100/metrics | head -10 ``` ### 6.9 — Recommended Dashboards Import these from [Grafana Dashboard Library](https://grafana.com/grafana/dashboards/): | Dashboard | ID | Purpose | |-----------|----|---------| | Node Exporter Full | 1860 | System metrics for N.O.M.A.D. | | Docker Container Stats | 893 | Container resource usage | | Netdata via Prometheus | (search) | PD and Serenity system metrics | To import: Grafana → Dashboards → New → Import → Enter dashboard ID. ### 6.10 — Metrics Targets Summary | Target | Host | Method | Endpoint | |--------|------|--------|----------| | N.O.M.A.D. system | localhost | node-exporter | node-exporter:9100 | | PD system | 10.5.1.X | Netdata Prometheus | 10.5.1.X:19999/api/v1/allmetrics | | Serenity system | 10.5.1.5 | Netdata Prometheus | 10.5.1.5:19999/api/v1/allmetrics | | ZFS pools | via Netdata | Netdata exports ZFS metrics | Included in Netdata scrape | | Container stats | via Netdata | Netdata exports cgroup metrics | Included in Netdata scrape | | Plex streams | Tautulli | n8n polling → Postgres | Via n8n workflow (Phase 3) | | qBit stats | qBittorrent API | n8n polling → Postgres | Via n8n workflow (Phase 3) | | Tailscale latency | Tailscale API | n8n polling → Postgres | Via n8n workflow (Phase 3) | ### 6.11 — n8n Integration (Grafana → n8n alert webhook) In Grafana → Alerting → Contact Points, create a webhook contact point: | Field | Value | |-------|-------| | Name | `n8n-alerts` | | Type | Webhook | | URL | `https://n8n.paccoco.com/webhook/grafana-alert` | | HTTP Method | POST | Then create alert rules for: - ZFS pool utilization > 85% - Container memory > 90% of limit - Host CPU sustained > 90% for 5 minutes - Disk I/O latency spikes These fire into the "Grafana Alert Remediation" n8n workflow (see Phase 3). --- ## Phase 7 — LiteLLM (AI Gateway) **Host:** PlausibleDeniability **Stack directory:** `/mnt/docker-ssd/docker/compose/ai/` (same stack as Qdrant) **Why:** Replaces manual multi-model routing with a unified OpenAI-compatible API. Every app (OpenWebUI, Continue.dev, n8n, Paperless summarization) points at one endpoint instead of juggling three Ollama IPs. ### 7.1 — What LiteLLM Does LiteLLM sits in front of your three Ollama instances and presents a single OpenAI-compatible API at `http://litellm:4000`. It handles: - **Model routing** — requests for `qwen3:32b` go to ROCINANTE, `qwen2.5:14b` goes to PD, `phi4` goes to N.O.M.A.D. - **Failover** — if ROCINANTE is offline, LiteLLM can fall back to PD automatically - **Load balancing** — distribute requests across instances running the same model - **Usage tracking** — logs token counts, latency, and costs per model/user via its built-in database ### 7.2 — Scaffold Directories ```bash # Config on SSD (SQLite DB for usage tracking) sudo mkdir -p /mnt/docker-ssd/docker/appdata/litellm # LiteLLM config file sudo mkdir -p /mnt/docker-ssd/docker/compose/ai/litellm ``` ### 7.3 — LiteLLM Config Create `/mnt/docker-ssd/docker/compose/ai/litellm/config.yaml`: ```yaml model_list: # ---- ROCINANTE (RTX 4090, 24GB) — heavy reasoning ---- - model_name: "heavy" litellm_params: model: "ollama/qwen3:32b" api_base: "http://10.5.1.ROCINANTE:11434" timeout: 300 stream_timeout: 300 model_info: description: "Heavy reasoning, long context, complex code" - model_name: "heavy" litellm_params: model: "ollama/deepseek-r1:32b" api_base: "http://10.5.1.ROCINANTE:11434" timeout: 300 stream_timeout: 300 model_info: description: "Deep reasoning fallback on ROCINANTE" # ---- PlausibleDeniability (RTX 2080 Ti, 11GB) — general ---- - model_name: "medium" litellm_params: model: "ollama/qwen2.5:14b" api_base: "http://host.docker.internal:11434" timeout: 120 stream_timeout: 120 model_info: description: "General homelab assistant, RAG queries" # ---- N.O.M.A.D. (GTX 1080, 8GB) — lightweight ---- - model_name: "light" litellm_params: model: "ollama/phi4" api_base: "http://10.5.1.16:11434" timeout: 60 stream_timeout: 60 model_info: description: "Fast local inference, lightweight tasks" - model_name: "light" litellm_params: model: "ollama/llama3.2:3b" api_base: "http://10.5.1.16:11434" timeout: 60 stream_timeout: 60 model_info: description: "Ultra-light fallback on N.O.M.A.D." # ---- Embeddings ---- - model_name: "embed" litellm_params: model: "ollama/nomic-embed-text" api_base: "http://host.docker.internal:11434" model_info: description: "Text embeddings for RAG pipeline" # ---- Direct model access (bypass routing) ---- # These let you request a specific model by its full name - model_name: "ollama/qwen3:32b" litellm_params: model: "ollama/qwen3:32b" api_base: "http://10.5.1.ROCINANTE:11434" - model_name: "ollama/qwen2.5:14b" litellm_params: model: "ollama/qwen2.5:14b" api_base: "http://host.docker.internal:11434" - model_name: "ollama/phi4" litellm_params: model: "ollama/phi4" api_base: "http://10.5.1.16:11434" litellm_settings: drop_params: true set_verbose: false request_timeout: 300 num_retries: 2 retry_after: 5 allowed_fails: 3 cooldown_time: 60 general_settings: master_key: "os.environ/LITELLM_MASTER_KEY" ``` > **DEPLOYED NOTE (2026-05-05):** Do NOT add `database_url` to general_settings — newer LiteLLM versions include Prisma ORM that requires PostgreSQL, and adding any database_url causes a crash loop. Without it, LiteLLM runs in config-only mode which is fine for homelab use. The `master_key` uses `os.environ/LITELLM_MASTER_KEY` syntax to read from the container's environment variable (set via .env → docker compose). > **Routing explained:** Multiple entries with the same `model_name` (like "heavy") enable load balancing and failover within that tier. When you request model `heavy`, LiteLLM picks the healthiest deployment. The direct-access entries (like `ollama/qwen3:32b`) let you bypass the tier system and target a specific model when needed. > **Replace IPs:** Update `10.5.1.ROCINANTE` with ROCINANTE's actual LAN or Tailscale IP. PD uses `host.docker.internal` since LiteLLM runs on PD alongside Ollama. ### 7.4 — docker-compose.yaml (updated ai stack) Update `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml` to add LiteLLM: ```yaml name: ai services: qdrant: image: qdrant/qdrant:v1.14.0 container_name: qdrant restart: unless-stopped ports: - "6333:6333" # REST API - "6334:6334" # gRPC environment: TZ: ${TZ} QDRANT__SERVICE__GRPC_PORT: 6334 QDRANT__STORAGE__STORAGE_PATH: /qdrant/storage QDRANT__STORAGE__SNAPSHOTS_PATH: /qdrant/snapshots volumes: - /mnt/docker-ssd/docker/appdata/qdrant/storage:/qdrant/storage - /mnt/docker-ssd/docker/appdata/qdrant/snapshots:/qdrant/snapshots networks: - ai-services - default litellm: image: ghcr.io/berriai/litellm:main-latest container_name: litellm restart: unless-stopped ports: - "4000:4000" environment: TZ: ${TZ} LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY} volumes: - /mnt/docker-ssd/docker/compose/ai/litellm/config.yaml:/app/config.yaml:ro - /mnt/docker-ssd/docker/appdata/litellm:/app/data command: ["--config", "/app/config.yaml", "--port", "4000"] extra_hosts: - "host.docker.internal:host-gateway" networks: - ai-services - default reranker: image: ghcr.io/huggingface/text-embeddings-inference:cpu-latest container_name: reranker restart: unless-stopped ports: - "8787:80" environment: MODEL_ID: ${RERANKER_MODEL} volumes: - /mnt/docker-ssd/docker/appdata/reranker:/data networks: - ai-services - default whisper: image: fedirz/faster-whisper-server:latest-cuda container_name: whisper restart: unless-stopped ports: - "8786:8000" environment: TZ: ${TZ} WHISPER__MODEL: ${WHISPER_MODEL} WHISPER__DEVICE: cuda WHISPER__COMPUTE_TYPE: float16 volumes: - /mnt/docker-ssd/docker/appdata/whisper:/root/.cache/huggingface deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] networks: - ai-services - default # ------------------------------------------------------- # Ollama and OpenWebUI go here when deployed. # They share this stack and the default + ai-services networks. # OpenWebUI → Qdrant at http://qdrant:6333 # OpenWebUI → LiteLLM at http://litellm:4000 (or direct Ollama) # ------------------------------------------------------- # ollama: # image: ollama/ollama:latest # container_name: ollama # ... # openwebui: # image: ghcr.io/open-webui/open-webui:main # container_name: openwebui # ... networks: ai-services: name: ai-services ``` > **GPU sharing note:** On PD, the RTX 2080 Ti is shared between Plex (hardware transcoding), Immich ML, and Ollama. faster-whisper will also need the GPU when processing audio. These workloads are bursty (not constant), so time-sharing the GPU is viable — but be aware that a large Whisper transcription job will temporarily impact Ollama inference latency. If this becomes an issue, consider running Whisper on N.O.M.A.D.'s GTX 1080 instead and change `whisper`'s Ollama-style routing accordingly. > **Reranker on CPU:** The reranker uses the `cpu-latest` image variant intentionally — do NOT pin to `cpu-1.5` as it has an hf-hub compatibility bug. Reranking is a lightweight operation (scoring ~50 chunks takes <1s on CPU) and doesn't justify competing for GPU VRAM. The model loads via safetensors fallback since no ONNX files exist for bge-reranker-v2-m3. Max batch size is 4 on CPU. If you find latency is an issue, a GPU variant exists (`ghcr.io/huggingface/text-embeddings-inference:turing-latest` for your 2080 Ti). ### 7.5 — .env.example (updated ai stack) ```bash # /mnt/docker-ssd/docker/compose/ai/.env.example TZ=America/New_York # LiteLLM LITELLM_MASTER_KEY=sk-CHANGE_ME # Reranker RERANKER_MODEL=BAAI/bge-reranker-v2-m3 # Whisper WHISPER_MODEL=Systran/faster-distil-whisper-large-v3 ``` ### 7.6 — Validate & Deploy ```bash cd /mnt/docker-ssd/docker/compose/ai cp .env.example .env nano .env # Set LITELLM_MASTER_KEY=$(openssl rand -hex 32) docker compose --env-file .env config docker compose --env-file .env up -d ``` ### 7.7 — Post-Deploy Verification ```bash # All containers running? docker ps --filter name=qdrant --filter name=litellm --filter name=reranker --filter name=whisper # LiteLLM health? curl -s http://localhost:4000/health # LiteLLM can see all models? curl -s http://localhost:4000/v1/models \ -H "Authorization: Bearer YOUR_MASTER_KEY" | python3 -m json.tool # Test a chat completion through LiteLLM curl -s http://localhost:4000/v1/chat/completions \ -H "Authorization: Bearer YOUR_MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "medium", "messages": [{"role": "user", "content": "Hello, which model are you?"}] }' | python3 -m json.tool # Test embeddings through LiteLLM curl -s http://localhost:4000/v1/embeddings \ -H "Authorization: Bearer YOUR_MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "embed", "input": "test embedding" }' | python3 -m json.tool ``` ### 7.8 — Integration Updates With LiteLLM deployed, all apps should point at `http://litellm:4000` (within the `ai-services` network) or `http://PD_IP:4000` (from external hosts) instead of direct Ollama endpoints. **OpenWebUI:** Settings → Connections → add OpenAI-compatible endpoint: - URL: `http://litellm:4000/v1` - API Key: your `LITELLM_MASTER_KEY` - This gives OpenWebUI access to all models across all three machines through one connection **Continue.dev:** Update `~/.continue/config.json`: ```json { "models": [ { "title": "Heavy (ROCINANTE)", "provider": "openai", "model": "heavy", "apiBase": "http://PD_TAILSCALE_IP:4000/v1", "apiKey": "YOUR_MASTER_KEY" }, { "title": "Medium (PD)", "provider": "openai", "model": "medium", "apiBase": "http://PD_TAILSCALE_IP:4000/v1", "apiKey": "YOUR_MASTER_KEY" } ], "tabAutocompleteModel": { "title": "Light (N.O.M.A.D.)", "provider": "openai", "model": "light", "apiBase": "http://PD_TAILSCALE_IP:4000/v1", "apiKey": "YOUR_MASTER_KEY" } } ``` > **Key change:** Continue.dev now uses `provider: "openai"` instead of `provider: "ollama"` and points at LiteLLM. One IP to remember, and if you add or move models between machines, you only update `config.yaml` — not every app. **n8n workflows:** All HTTP Request nodes that call Ollama directly should be updated: - Old: `http://10.5.1.16:11434/api/generate` (N.O.M.A.D.) - New: `http://litellm:4000/v1/chat/completions` with `model: "light"` - n8n is on the `ai-services` network, so it reaches LiteLLM by Docker DNS **n8n Multi-Model Query Router workflow:** This workflow is now **simplified dramatically** — instead of a Switch node routing to three different Ollama IPs, it becomes a single HTTP Request node that passes the model tier name (`light`, `medium`, `heavy`) to LiteLLM and lets the gateway handle routing. The Switch node logic can be removed entirely. --- ## Phase 8 — Reranker (RAG Quality) **Host:** PlausibleDeniability (deployed as part of the `ai` stack in Phase 7) **Service:** `reranker` container (already in the compose above) **Why:** Dramatically improves RAG answer quality by filtering out noisy retrieval results before they reach the LLM. ### 8.1 — How Reranking Works Without a reranker, your RAG pipeline does: ``` Query → embed → Qdrant top-10 by vector similarity → all 10 chunks go to LLM ``` The problem: vector similarity often returns "close but irrelevant" chunks. The LLM gets noisy context and hallucinates. With a reranker: ``` Query → embed → Qdrant top-50 by vector similarity → reranker scores each (query, chunk) pair → top-5 by relevance go to LLM ``` The reranker is a cross-encoder that reads the full query AND each chunk together, producing a much more accurate relevance score than vector distance alone. It over-retrieves cheaply from Qdrant, then precisely filters. ### 8.2 — Scaffold Directories ```bash # Model cache on SSD (reranker model is ~1.1GB, downloaded on first start) sudo mkdir -p /mnt/docker-ssd/docker/appdata/reranker ``` ### 8.3 — Post-Deploy Verification ```bash # Container running? docker ps --filter name=reranker # Health check? curl -s http://localhost:8787/health # Test reranking curl -s http://localhost:8787/rerank \ -H "Content-Type: application/json" \ -d '{ "query": "How do I restart a Docker container?", "texts": [ "Use docker restart to restart a running container.", "Docker was founded in 2013 by Solomon Hykes.", "The docker compose down command stops and removes containers.", "Kubernetes pods can be restarted by deleting them." ] }' | python3 -m json.tool # Expected: the first text scores highest, second scores lowest ``` ### 8.4 — n8n RAG Pipeline Integration Update the "Qdrant Index Updater" and any RAG query workflows to include a reranking step. **Updated RAG Query Workflow (for OpenWebUI or any n8n-based query):** ``` Trigger: Webhook node (POST with query) Step 1: HTTP Request → LiteLLM /v1/embeddings - model: "embed" - input: query text Step 2: HTTP Request → Qdrant API (POST /collections/{name}/points/search) - vector: embedding from step 1 - limit: 50 (over-retrieve) Step 3: HTTP Request → Reranker (POST http://reranker:80/rerank) - query: original query text - texts: array of 50 chunk texts from Qdrant results Step 4: Code node → take top 5 by reranker score, format as context Step 5: HTTP Request → LiteLLM /v1/chat/completions - model: "medium" (or "heavy" for complex queries) - messages: system prompt with top-5 context + user query Step 6: Return response via webhook ``` > **Key difference from the original plan:** Step 2 now retrieves 50 results instead of 10, and Step 3 (reranking) filters down to the best 5. This "over-retrieve then rerank" pattern is the standard approach for production RAG systems. --- ## Phase 9 — faster-whisper (Speech-to-Text) **Host:** PlausibleDeniability (deployed as part of the `ai` stack in Phase 7) **Service:** `whisper` container (already in the compose above) **Why:** Replaces shelved Scriberr with an OpenAI-compatible STT API. No SQLite dependency, no ZFS/ACL issues. ### 9.1 — Scaffold Directories ```bash # Model cache on SSD (Whisper models are 1-3GB) sudo mkdir -p /mnt/docker-ssd/docker/appdata/whisper ``` ### 9.2 — Post-Deploy Verification ```bash # Container running? docker ps --filter name=whisper # Health check? curl -s http://localhost:8786/health # Test transcription with a sample audio file curl -s http://localhost:8786/v1/audio/transcriptions \ -F "file=@/path/to/test-audio.wav" \ -F "model=Systran/faster-distil-whisper-large-v3" \ | python3 -m json.tool ``` ### 9.3 — n8n Integration Workflows **Workflow: Voice Note → Text → Summary** ``` Trigger: Webhook node (POST with audio file in body) Step 1: HTTP Request → Whisper (POST http://whisper:8000/v1/audio/transcriptions) - Multipart form with audio file - response_format: "json" Step 2: HTTP Request → LiteLLM /v1/chat/completions - model: "medium" - Prompt: "Summarize this voice note concisely: {transcript}" Step 3: HTTP Request → Gotify → push summary to phone Step 4: (Optional) Postgres node → log transcript and summary ``` **Workflow: Audio File → Paperless Document** ``` Trigger: Webhook or filesystem watcher Step 1: HTTP Request → Whisper → get transcript Step 2: Code node → format transcript as text document Step 3: HTTP Request → Paperless API (POST /api/documents/post_document/) - Upload transcript as .txt - Tag: "transcription" Step 4: Gotify notification: "Audio transcribed and filed: {title}" ``` **Home Assistant Voice Integration:** If you want HA voice commands, Whisper can serve as the STT backend: 1. In Home Assistant → Settings → Voice Assistants 2. Add speech-to-text provider: "Whisper" at `http://PD_IP:8786` 3. Pair with Piper TTS (future addition) for full voice assistant loop ### 9.4 — GPU vs CPU Considerations The compose file above uses the GPU variant with CUDA. If GPU contention with Ollama becomes an issue: **Option A — CPU fallback on PD:** Change the image from `latest-cuda` to `fedirz/faster-whisper-server:latest-cpu` and remove the `deploy.resources` block and the CUDA environment variables (`WHISPER__DEVICE`, `WHISPER__COMPUTE_TYPE`). Transcription will be slower (~4x real-time instead of ~20x) but won't compete for VRAM. **Option B — Move to N.O.M.A.D.:** Run Whisper on N.O.M.A.D.'s GTX 1080 which is less contested. Add it to N.O.M.A.D.'s compose or run standalone. N.O.M.A.D.'s 8GB VRAM handles Whisper models comfortably since phi4 doesn't fill it. --- ## Additional Tools Setup ### Continue.dev (Local AI Code Completion) 1. Install the Continue extension in VS Code 2. Create/edit `~/.continue/config.json`: **With LiteLLM (recommended — see Phase 7):** ```json { "models": [ { "title": "Heavy (ROCINANTE)", "provider": "openai", "model": "heavy", "apiBase": "http://PD_TAILSCALE_IP:4000/v1", "apiKey": "YOUR_LITELLM_MASTER_KEY" }, { "title": "Medium (PD)", "provider": "openai", "model": "medium", "apiBase": "http://PD_TAILSCALE_IP:4000/v1", "apiKey": "YOUR_LITELLM_MASTER_KEY" } ], "tabAutocompleteModel": { "title": "Light (N.O.M.A.D.)", "provider": "openai", "model": "light", "apiBase": "http://PD_TAILSCALE_IP:4000/v1", "apiKey": "YOUR_LITELLM_MASTER_KEY" } } ``` > One IP, one API key, all three machines. If you add or move models, update LiteLLM's `config.yaml` — not every app. **Without LiteLLM (direct Ollama, if Phase 7 is not yet deployed):** ```json { "models": [ { "title": "PD - qwen2.5:14b", "provider": "ollama", "model": "qwen2.5:14b", "apiBase": "http://PD_TAILSCALE_IP:11434" }, { "title": "ROCINANTE - qwen3:32b", "provider": "ollama", "model": "qwen3:32b", "apiBase": "http://ROCINANTE_TAILSCALE_IP:11434" } ], "tabAutocompleteModel": { "title": "N.O.M.A.D. - phi4", "provider": "ollama", "model": "phi4", "apiBase": "http://NOMAD_TAILSCALE_IP:11434" } } ``` ### Obsidian + Gitea Sync 1. In Obsidian, install the "Obsidian Git" community plugin 2. Initialize your vault as a git repo: ```bash cd /path/to/obsidian/vault git init git remote add origin http://PD_IP:3000/fizzlepoof/obsidian-vault.git ``` 3. In Obsidian Git settings: - Auto backup interval: 10 minutes - Pull on startup: enabled 4. Create the repo in Gitea first: `http://PD_IP:3000` → New Repository → `obsidian-vault` 5. Add a Gitea webhook to trigger the n8n "Qdrant Index Updater" workflow (see Phase 3) ### Homepage Ollama Widget Add to your Homepage configuration (`/mnt/tank/docker/appdata/homepage/services.yaml`): ```yaml - AI: - Ollama (PD): icon: ollama.svg href: http://PD_IP:11434 widget: type: ollama url: http://PD_IP:11434 - Ollama (ROCINANTE): icon: ollama.svg href: http://ROCINANTE_IP:11434 widget: type: ollama url: http://ROCINANTE_IP:11434 ``` --- ## Deployment Order Summary ``` Week 1: Phase 1 — Gotify └─ Deploy, create app tokens, install phone app └─ Test: send manual notification via API Week 1: Phase 2 — Qdrant └─ Deploy, verify REST API └─ Create initial collections (empty, ready for n8n) Week 2: Phase 3 — n8n └─ Deploy, create admin account └─ Build workflows incrementally: Day 1: Gitea commit → Gotify (simplest, proves the pipeline) Day 2: Sonarr/Radarr → TMDB → Gotify + Discord Day 3: Tautulli play logging + weekly digest Day 4: Uptime Kuma enhanced alerts Day 5: ZFS pool monitoring Day 6: Multi-model query router Day 7: Qdrant index updater Week 3: Phase 4 — Paperless-NGX └─ Deploy, ingest test documents └─ Build n8n workflow: email → Paperless → Ollama summary └─ Set up document tags and correspondents for L&L Crafts Week 4: Phase 5 — Home Assistant └─ Deploy, onboard, add integrations └─ Connect to n8n via long-lived access token └─ Set up smart plug monitoring Week 4: Phase 6 — Grafana + Prometheus (on N.O.M.A.D.) └─ Deploy, verify scrape targets └─ Import dashboards └─ Set up Grafana → n8n alert webhook └─ Build alert rules Week 5: Phase 7/8/9 — AI Stack Expansion (all deploy together in ai stack) └─ LiteLLM: deploy, verify model routing across all 3 machines └─ Reranker: deploy, test scoring with sample chunks └─ faster-whisper: deploy, test transcription └─ Update OpenWebUI, Continue.dev, and n8n to use LiteLLM endpoint └─ Update RAG workflows to include reranking step └─ Build voice note → transcription → summary workflow ``` --- ## Post-Deployment Validation Master Checklist Run through this after all six phases are deployed: ### Infrastructure Health - [ ] `docker ps` on PD shows: gotify, n8n, qdrant, litellm, reranker, whisper, paperless, paperless-gotenberg, paperless-tika, homeassistant — all healthy - [ ] `docker ps` on N.O.M.A.D. shows: prometheus, grafana, node-exporter — all healthy - [ ] All services accessible via Pangolin subdomains (gotify, n8n, paperless, ha, grafana) ### Network Connectivity - [ ] n8n can reach shared-postgres (test: check n8n logs for successful DB migration) - [ ] n8n can reach Gotify (test: trigger a workflow that sends a notification) - [ ] n8n can reach Qdrant at `http://qdrant:6333` via `ai-services` network (test: query collections from n8n HTTP node) - [ ] n8n can reach LiteLLM at `http://litellm:4000` via `ai-services` network (test: send a chat completion) - [ ] LiteLLM can route to all 3 Ollama instances (test: request model "light", "medium", "heavy" and verify each responds) - [ ] Reranker responds at `http://reranker:80/rerank` (test: POST sample texts) - [ ] Whisper responds at `http://whisper:8000/v1/audio/transcriptions` (test: transcribe a sample .wav) - [ ] Paperless can reach shared-postgres and shared-redis - [ ] Prometheus can scrape PD and Serenity Netdata endpoints - [ ] Grafana alert webhook reaches n8n ### Data Flow End-to-End - [ ] Sonarr webhook → n8n → Gotify push on phone - [ ] Gitea commit → n8n → Qdrant indexing → Gotify notification - [ ] RAG query → Qdrant top-50 → reranker top-5 → LiteLLM → accurate answer - [ ] Audio file → Whisper transcription → n8n → Gotify summary - [ ] Document dropped in consume folder → Paperless OCR → n8n summary → Gotify - [ ] Grafana alert fires → n8n webhook → remediation action or Gotify alert - [ ] HA presence change → n8n → server wake/sleep ### Backup Considerations - [ ] Gotify SQLite DB is on SSD — include `/mnt/docker-ssd/docker/appdata/gotify` in backup plan - [ ] Qdrant storage on SSD — include `/mnt/docker-ssd/docker/appdata/qdrant` in backup plan - [ ] n8n config/workflows — include `/mnt/tank/docker/appdata/n8n` in backup plan - [ ] Paperless media — include `/mnt/tank/docker/appdata/paperless/media` in backup plan - [ ] HA config — include `/mnt/docker-ssd/docker/appdata/homeassistant` in backup plan - [ ] LiteLLM DB + config — include `/mnt/docker-ssd/docker/appdata/litellm` and `compose/ai/litellm/config.yaml` in backup plan - [ ] Grafana/Prometheus data — include `/mnt/hdd-2/grafana-data` and `/mnt/hdd-2/prometheus-data` in N.O.M.A.D. backup plan - [ ] All new databases (n8n, paperless) are in shared-postgres — ensure pg_dump covers them ### Storage Capacity Check - [ ] `docker-ssd` (500GB Crucial MX500) still has headroom after adding Gotify, Qdrant, HA, Paperless data - [ ] `tank` mirror has room for n8n config + Paperless media + consume/export - [ ] N.O.M.A.D. hdd-2 has room for Prometheus TSDB (90-day retention) + Grafana data --- > **Remember:** Everything self-hosted. Nothing touches the cloud.