2079 lines
67 KiB
Markdown
2079 lines
67 KiB
Markdown
# 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/<service>` | Write-heavy, SQLite, GPU/model, databases |
|
|
| Tank | `/mnt/tank/docker/appdata/<service>` | 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:13.0.1` — Grafana 13 is the current stable.
|
|
> - `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 <container_name> 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.
|