diff --git a/AGENTS.md b/AGENTS.md index c6e4e06..8e4e54f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,20 @@ # Agent notes (arr-stack) +## Project specification (read first) + +| File | Audience | +|------|----------| +| **`specification.json`** | Coding agents / tools — structured index of the whole stack | +| **`specification.html`** | Humans — same content, readable | + +**Do not edit those two files by hand.** Update `scripts/generate-specification.py` (`SPEC` dict), then run: + +```bash +./scripts/generate-specification.py +``` + +Commit **both** outputs together so they stay in lockstep. Prefer JSON over HTML for agent context. + ## Git - Remote: `origin` → `ssh://git@192.168.8.123:2222/tim/stack.git` (Forgejo `tim/stack`) diff --git a/quickstart.md b/quickstart.md index 652f8a6..f4c244e 100644 --- a/quickstart.md +++ b/quickstart.md @@ -3,6 +3,9 @@ Homelab host: **`192.168.8.123`** (SER5, Ubuntu 24.04) Stack: **`/opt/stack`** · Media: **`/storage`** · User: **`tim`** +**Full project index (session recovery):** [specification.html](./specification.html) (human) · [specification.json](./specification.json) (agents). +Regenerate both from one source: `./scripts/generate-specification.py` + --- ## Fresh install / tech demo (max automation) diff --git a/scripts/generate-specification.py b/scripts/generate-specification.py new file mode 100755 index 0000000..48a586a --- /dev/null +++ b/scripts/generate-specification.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python3 +""" +Single source of truth for project specification. + +Writes: + specification.json — for coding agents / tools + specification.html — for humans + +Run after architecture changes: + ./scripts/generate-specification.py + +Why JSON for agents (not only HTML): + - Stable keys, easy to grep/parse + - No layout noise + - Matches how tools ingest structured context +HTML is a readable projection of the same data — never edit HTML by hand. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC_VERSION = "1.2.0" +GENERATED_AT = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + +# --------------------------------------------------------------------------- +# Canonical specification (edit THIS, then re-run the generator) +# --------------------------------------------------------------------------- + +SPEC = { + "meta": { + "name": "arr-stack", + "title": "Homelab media + local AI stack", + "version": SPEC_VERSION, + "generated_at": GENERATED_AT, + "generator": "scripts/generate-specification.py", + "repository": { + "forgejo": "http://192.168.8.123:3002/tim/stack", + "clone_ssh": "ssh://git@192.168.8.123:2222/tim/stack.git", + "default_branch": "main", + }, + "primary_host": { + "hostname": "stack", + "lan_ip": "192.168.8.123", + "user": "tim", + "os": "Ubuntu Server 24.04", + "hardware": "AMD Ryzen 7 5800H + Radeon Graphics (shared RAM), ~28–32 GB RAM", + "stack_root": "/opt/stack", + "media_root": "/storage", + }, + "demo_credentials": { + "username": "tim", + "password": "asdfasdf", + "note": "Tech demo only; used for local UIs when configured that way", + }, + "docs_for_humans": [ + "specification.html", + "quickstart.md", + "README.md", + ], + "docs_for_agents": [ + "specification.json", + "AGENTS.md", + "quickstart.md", + "profiles/*.env", + "docker-compose.yml", + ], + "never_commit": [ + ".env", + "secrets/.env", + "wireguard/wg0.conf", + "config/", + "models/**/*.gguf", + "data/", + ], + }, + "architecture": { + "summary": ( + "Docker Compose stack combining VPN-locked torrents (*arr + qBittorrent via gluetun), " + "Usenet (SABnzbd), media (Jellyfin), local LLM inference (Prism llama-server + Open WebUI), " + "Forgejo git, OpenHands agent, Caddy LAN landing page, and Cloudflare DDNS." + ), + "networks": { + "arr-net": "Bridge network for most services", + "gluetun_namespace": "qbittorrent uses network_mode: service:gluetun (VPN killswitch)", + "host_network": ["cloudflare-ddns"], + }, + "data_layout": { + "CONFIG_DIR": "App configs (default /opt/stack/config on server)", + "DATA_DIR": "Shared media tree (default /storage on server)", + "paths": { + "media": "/data/media/{movies,tv,music}", + "torrents": "/data/torrents/{movies,tv,music}", + "usenet": "/data/usenet/incomplete and /data/usenet/complete/{movies,tv,music}", + "models": "models/bonsai/*.gguf mounted read-only at /models in bonsai", + "workspace": "AI_WORKSPACE_DIR for Open WebUI + OpenHands", + }, + }, + "security_model": { + "torrents": "Only via WireGuard through gluetun; no BitTorrent port on host", + "usenet": "Direct SSL to provider (not through gluetun)", + "lan_firewall": "UFW allow service ports from LAN_SUBNET only", + "landing_and_uis": "Plain HTTP on LAN (no TLS certs yet)", + }, + }, + "hardware_profiles": { + "how_to_apply": "./scripts/apply-profile.sh mini|mid|gaming", + "files": { + "mini": "profiles/mini.env", + "mid": "profiles/mid.env", + "gaming": "profiles/gaming.env", + }, + "profiles": { + "mini": { + "hardware": "Beelink / SER5-class mini PC", + "inference": "cpu", + "ngl": 0, + "default_ctx": 32768, + "threads": 12, + "default_model": "Ternary-Bonsai-27B-Q2_0.gguf", + "model_repo": "prism-ml/Ternary-Bonsai-27B-gguf", + "notes": "CPU preferred on APU; do not force NGL in docker-compose.override.yml", + }, + "mid": { + "hardware": "~8GB AMD discrete GPU, ~16GB system RAM", + "inference": "vulkan", + "ngl": 99, + "default_ctx": 8192, + "threads": 8, + "kv4": True, + "default_model": "Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf", + "model_repo": "mradermacher/Qwen3.5-9B-abliterated-v2-MAX-GGUF", + "notes": "Fits VRAM; use KV4 for memory", + }, + "gaming": { + "hardware": "~16GB AMD GPU, ~64GB system RAM", + "inference": "vulkan", + "ngl": 99, + "default_ctx": 32768, + "threads": 16, + "default_model": "Ternary-Bonsai-27B-Q2_0.gguf", + "also_download": ["Qwen3.5-9B Q4_K_M"], + "notes": "Full Vulkan + large context", + }, + }, + "models": { + "bonsai27": { + "file": "Ternary-Bonsai-27B-Q2_0.gguf", + "repo": "prism-ml/Ternary-Bonsai-27B-gguf", + "runtime": "PrismML llama.cpp fork (custom Q2_0)", + "size_approx": "~7.2 GB", + }, + "qwen9_abliterated_q4km": { + "file": "Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf", + "repo": "mradermacher/Qwen3.5-9B-abliterated-v2-MAX-GGUF", + "runtime": "Same Prism llama-server image (standard GGUF)", + "size_approx": "~5.6 GB", + }, + }, + }, + "services": [ + { + "id": "landing", + "name": "Landing (Caddy)", + "image": "caddy:2-alpine", + "port": 80, + "url": "http://HOST/", + "purpose": "Dark-mode LAN resource map", + "network": "arr-net", + "category": "gateway", + }, + { + "id": "gluetun", + "name": "gluetun", + "image": "qmcgaw/gluetun:v3", + "port": None, + "published_ports": ["8080:8080 (qBittorrent WebUI)"], + "purpose": "WireGuard VPN + killswitch", + "network": "arr-net", + "category": "vpn", + "config": "wireguard/wg0.conf", + }, + { + "id": "qbittorrent", + "name": "qBittorrent", + "image": "lscr.io/linuxserver/qbittorrent:latest", + "port": 8080, + "url": "http://HOST:8080/", + "purpose": "Torrent client (VPN-only via gluetun)", + "network": "service:gluetun", + "category": "download", + }, + { + "id": "sabnzbd", + "name": "SABnzbd", + "image": "lscr.io/linuxserver/sabnzbd:latest", + "port": 8085, + "url": "http://HOST:8085/", + "purpose": "Usenet NZB downloader", + "network": "arr-net", + "category": "download", + }, + { + "id": "prowlarr", + "name": "Prowlarr", + "image": "lscr.io/linuxserver/prowlarr:latest", + "port": 9696, + "url": "http://HOST:9696/", + "purpose": "Indexer manager (torrents + NZB)", + "network": "arr-net", + "category": "download", + }, + { + "id": "flaresolverr", + "name": "FlareSolverr", + "image": "ghcr.io/flaresolverr/flaresolverr:latest", + "port": 8191, + "url": "http://flaresolverr:8191 (internal)", + "purpose": "Cloudflare JS challenge proxy for Prowlarr", + "network": "arr-net", + "category": "download", + "published": False, + }, + { + "id": "sonarr", + "name": "Sonarr", + "image": "lscr.io/linuxserver/sonarr:latest", + "port": 8989, + "url": "http://HOST:8989/", + "purpose": "TV automation", + "network": "arr-net", + "category": "media", + }, + { + "id": "radarr", + "name": "Radarr", + "image": "lscr.io/linuxserver/radarr:latest", + "port": 7878, + "url": "http://HOST:7878/", + "purpose": "Movie automation", + "network": "arr-net", + "category": "media", + }, + { + "id": "lidarr", + "name": "Lidarr", + "image": "lscr.io/linuxserver/lidarr:latest", + "port": 8686, + "url": "http://HOST:8686/", + "purpose": "Music automation", + "network": "arr-net", + "category": "media", + }, + { + "id": "bazarr", + "name": "Bazarr", + "image": "lscr.io/linuxserver/bazarr:latest", + "port": 6767, + "url": "http://HOST:6767/", + "purpose": "Subtitles", + "network": "arr-net", + "category": "media", + }, + { + "id": "jellyfin", + "name": "Jellyfin", + "image": "lscr.io/linuxserver/jellyfin:latest", + "port": 8096, + "url": "http://HOST:8096/", + "purpose": "Media server / playback", + "network": "arr-net", + "category": "media", + }, + { + "id": "bonsai", + "name": "Bonsai (llama-server)", + "image": "arr-stack/bonsai:local (ai/Dockerfile)", + "port": 8081, + "url": "http://HOST:8081/v1", + "purpose": "Local LLM OpenAI-compatible API (Prism build)", + "network": "arr-net", + "category": "ai", + }, + { + "id": "open-webui", + "name": "Open WebUI", + "image": "ghcr.io/open-webui/open-webui:main", + "port": 3000, + "url": "http://HOST:3000/", + "purpose": "Chat UI + RAG + tools", + "network": "arr-net", + "category": "ai", + "llm_base": "http://bonsai:8080/v1", + }, + { + "id": "searxng", + "name": "SearXNG", + "image": "docker.io/searxng/searxng:latest", + "port": 8888, + "url": "http://127.0.0.1:8888/ (localhost only)", + "purpose": "Private search for Open WebUI RAG", + "network": "arr-net", + "category": "ai", + "published": "loopback", + }, + { + "id": "openhands", + "name": "OpenHands", + "image": "ghcr.io/all-hands-ai/openhands:0.54", + "port": 3001, + "url": "http://HOST:3001/", + "purpose": "AI coding agent (always deployed)", + "network": "arr-net", + "category": "ai", + "note": "No Forgejo integration in UI — clone repos into workspace", + }, + { + "id": "forgejo", + "name": "Forgejo", + "image": "codeberg.org/forgejo/forgejo:11", + "port": 3002, + "url": "http://HOST:3002/", + "ssh_port": 2222, + "purpose": "Self-hosted Git", + "network": "arr-net", + "category": "code", + }, + { + "id": "cloudflare-ddns", + "name": "Cloudflare DDNS", + "image": "timothyjmiller/cloudflare-ddns:latest", + "port": None, + "url": None, + "purpose": "Update public IPv4 A records for domain + subdomains", + "network": "host", + "category": "dns", + "domain": "devopshomelab.com", + "subdomains": ["mc", "minecraft", "vpn", "www", "forgejo", "jellyfin"], + }, + ], + "scripts": { + "generate-specification.py": "Regenerate specification.json + specification.html (this file)", + "apply-profile.sh": "Merge profiles/{mini,mid,gaming}.env into .env", + "bootstrap-stack.sh": "Fresh bring-up: dirs, models, compose up all, heal gluetun, usenet, cloudflare", + "download-models.sh": "Download Bonsai 27B and/or Qwen 9B GGUF into models/bonsai", + "download-bonsai-model.sh": "Wrapper → download-models.sh", + "configure-usenet.sh": "SABnzbd folders + Newshosting/Tweaknews from secrets/.env", + "render-cloudflare-ddns-env.sh": "Build config/cloudflare-ddns/ddns.env from secrets", + "gluetun-endpoint-watch.sh": "If gluetun unhealthy: dig WG host, update Endpoint, recreate gluetun+qbit", + }, + "ansible": { + "entry": "ansible/site.yml", + "inventory": "ansible/inventory/hosts.ini", + "roles": ["common", "docker", "amd_vulkan", "firewall", "stack", "users"], + "stack_deploy_method": "git (default) or rsync", + "server_repo": "/opt/stack tracks Forgejo via git pull", + "timers": ["arr-stack.service", "gluetun-endpoint-watch.timer"], + }, + "workflows": { + "fresh_install": [ + "cp .env.example .env; fill STACK_HOST, LAN_SUBNET, usenet, cloudflare keys", + "Place wireguard/wg0.conf (Endpoint IP + # EndpointHost = hostname)", + "./scripts/apply-profile.sh mini|mid|gaming", + "./scripts/bootstrap-stack.sh", + "One-time UI: Prowlarr apps, Jellyfin libraries, Forgejo/Open WebUI admin (demo user/pass)", + ], + "day_to_day_server": [ + "cd /opt/stack && git pull", + "docker compose up -d", + "rebuild bonsai if Dockerfile/profile backend changed", + ], + "after_secrets_change": { + "cloudflare": [ + "./scripts/render-cloudflare-ddns-env.sh", + "docker compose up -d cloudflare-ddns --force-recreate", + ], + "usenet_servers": ["./scripts/configure-usenet.sh", "Test servers in SABnzbd UI"], + "nzbgeek": ["Update API key in Prowlarr UI"], + "wireguard": [ + "Edit wireguard/wg0.conf", + "docker compose up -d gluetun --force-recreate", + "or ./scripts/gluetun-endpoint-watch.sh", + ], + "stack_env": ["Edit .env", "docker compose up -d (+ build bonsai if backend changes)"], + }, + "switch_profile": [ + "./scripts/apply-profile.sh mid", + "docker compose build bonsai && docker compose up -d", + ], + "agent_git": [ + "Commit meaningful changes with complete sentences", + "Push to origin main (Forgejo)", + "Never commit secrets", + "After architecture changes: run generate-specification.py and commit both outputs", + ], + }, + "compose": { + "file": "docker-compose.yml", + "override": "docker-compose.override.yml", + "always_deploy_all": True, + "note": "OpenHands is not behind a compose profile; full stack always starts", + "override_rules": [ + "Do not put group_add names (video/render) — use host GIDs or 0666 /dev/dri", + "Do not force bonsai NGL in override on mini (overrides .env)", + "mini: jellyfin /dev/dri only; mid/gaming: add bonsai devices for Vulkan", + ], + }, + "env_and_secrets": { + "stack_env": ".env (from .env.example) — ports, paths, AI, bootstrap flags, demo login", + "secrets_env": "secrets/.env — usenet + cloudflare API (gitignored)", + "approx_env_keys": 80, + "demo_user": "tim", + "demo_pass": "asdfasdf", + }, +} + + +def write_json(path: Path) -> None: + path.write_text(json.dumps(SPEC, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _esc(s: object) -> str: + return ( + str(s) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def write_html(path: Path) -> None: + m = SPEC["meta"] + host = m["primary_host"]["lan_ip"] + services = SPEC["services"] + profiles = SPEC["hardware_profiles"]["profiles"] + scripts = SPEC["scripts"] + workflows = SPEC["workflows"] + + def svc_rows() -> str: + rows = [] + for s in services: + url = (s.get("url") or "—").replace("HOST", host) + port = s.get("port") + port_s = str(port) if port is not None else "—" + rows.append( + "" + f"{_esc(s['id'])}" + f"{_esc(s['name'])}" + f"{_esc(s.get('category', ''))}" + f"{_esc(port_s)}" + f"{_esc(url)}" + f"{_esc(s['purpose'])}" + "" + ) + return "\n".join(rows) + + def profile_cards() -> str: + cards = [] + for name, p in profiles.items(): + cards.append( + f""" +
+

{_esc(name)}

+

{_esc(p['hardware'])}

+ +
""" + ) + return "\n".join(cards) + + def script_list() -> str: + return "\n".join( + f"
  • {_esc(k)} — {_esc(v)}
  • " for k, v in scripts.items() + ) + + def workflow_secrets() -> str: + parts = [] + for k, steps in workflows["after_secrets_change"].items(): + items = "".join(f"
  • {_esc(s)}
  • " for s in steps) + parts.append(f"

    {_esc(k)}

      {items}
    ") + return "\n".join(parts) + + html = f""" + + + + + {_esc(m['title'])} — specification + + + + +
    +

    {_esc(m['title'])}

    +

    {_esc(SPEC['architecture']['summary'])}

    +
    + spec v{_esc(m['version'])} + generated {_esc(m['generated_at'])} + host {_esc(host)} + stack {_esc(m['primary_host']['stack_root'])} +
    + + + +

    Primary host

    + + +

    Hardware profiles

    +

    Apply with ./scripts/apply-profile.sh mini|mid|gaming

    +
    + {profile_cards()} +
    + +

    Services

    +

    All services deploy by default (docker compose up -d). Replace HOST with {_esc(host)}. Links use plain http:// (no TLS).

    + + + + + + {svc_rows()} + +
    IDNameCategoryPortURLPurpose
    + +

    Scripts

    + + +

    After changing secrets

    +

    Edit secrets/.env first, then:

    + {workflow_secrets()} + +

    Fresh install

    +
      + {''.join(f'
    1. {_esc(s)}
    2. ' for s in workflows['fresh_install'])} +
    + +

    Day-to-day on server

    +
      + {''.join(f'
    1. {_esc(s)}
    2. ' for s in workflows['day_to_day_server'])} +
    + +

    Ansible

    + + +

    Never commit

    + + + +
    + + +""" + path.write_text(html, encoding="utf-8") + + +def main() -> None: + json_path = ROOT / "specification.json" + html_path = ROOT / "specification.html" + write_json(json_path) + write_html(html_path) + print(f"Wrote {json_path.relative_to(ROOT)}") + print(f"Wrote {html_path.relative_to(ROOT)}") + print(f"spec version {SPEC_VERSION} @ {GENERATED_AT}") + + +if __name__ == "__main__": + main() diff --git a/specification.html b/specification.html new file mode 100644 index 0000000..8228110 --- /dev/null +++ b/specification.html @@ -0,0 +1,194 @@ + + + + + + Homelab media + local AI stack — specification + + + + +
    +

    Homelab media + local AI stack

    +

    Docker Compose stack combining VPN-locked torrents (*arr + qBittorrent via gluetun), Usenet (SABnzbd), media (Jellyfin), local LLM inference (Prism llama-server + Open WebUI), Forgejo git, OpenHands agent, Caddy LAN landing page, and Cloudflare DDNS.

    +
    + spec v1.2.0 + generated 2026-07-23T02:54:38Z + host 192.168.8.123 + stack /opt/stack +
    + + + +

    Primary host

    + + +

    Hardware profiles

    +

    Apply with ./scripts/apply-profile.sh mini|mid|gaming

    +
    + +
    +

    mini

    +

    Beelink / SER5-class mini PC

    +
      +
    • Inference: cpu (ngl=0)
    • +
    • Context: 32768
    • +
    • Model: Ternary-Bonsai-27B-Q2_0.gguf
    • +
    • CPU preferred on APU; do not force NGL in docker-compose.override.yml
    • +
    +
    + +
    +

    mid

    +

    ~8GB AMD discrete GPU, ~16GB system RAM

    +
      +
    • Inference: vulkan (ngl=99)
    • +
    • Context: 8192
    • +
    • Model: Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf
    • +
    • Fits VRAM; use KV4 for memory
    • +
    +
    + +
    +

    gaming

    +

    ~16GB AMD GPU, ~64GB system RAM

    +
      +
    • Inference: vulkan (ngl=99)
    • +
    • Context: 32768
    • +
    • Model: Ternary-Bonsai-27B-Q2_0.gguf
    • +
    • Full Vulkan + large context
    • +
    +
    +
    + +

    Services

    +

    All services deploy by default (docker compose up -d). Replace HOST with 192.168.8.123. Links use plain http:// (no TLS).

    + + + + + + + + + + + + + + + + + + + + + + + +
    IDNameCategoryPortURLPurpose
    landingLanding (Caddy)gateway80http://192.168.8.123/Dark-mode LAN resource map
    gluetungluetunvpnWireGuard VPN + killswitch
    qbittorrentqBittorrentdownload8080http://192.168.8.123:8080/Torrent client (VPN-only via gluetun)
    sabnzbdSABnzbddownload8085http://192.168.8.123:8085/Usenet NZB downloader
    prowlarrProwlarrdownload9696http://192.168.8.123:9696/Indexer manager (torrents + NZB)
    flaresolverrFlareSolverrdownload8191http://flaresolverr:8191 (internal)Cloudflare JS challenge proxy for Prowlarr
    sonarrSonarrmedia8989http://192.168.8.123:8989/TV automation
    radarrRadarrmedia7878http://192.168.8.123:7878/Movie automation
    lidarrLidarrmedia8686http://192.168.8.123:8686/Music automation
    bazarrBazarrmedia6767http://192.168.8.123:6767/Subtitles
    jellyfinJellyfinmedia8096http://192.168.8.123:8096/Media server / playback
    bonsaiBonsai (llama-server)ai8081http://192.168.8.123:8081/v1Local LLM OpenAI-compatible API (Prism build)
    open-webuiOpen WebUIai3000http://192.168.8.123:3000/Chat UI + RAG + tools
    searxngSearXNGai8888http://127.0.0.1:8888/ (localhost only)Private search for Open WebUI RAG
    openhandsOpenHandsai3001http://192.168.8.123:3001/AI coding agent (always deployed)
    forgejoForgejocode3002http://192.168.8.123:3002/Self-hosted Git
    cloudflare-ddnsCloudflare DDNSdnsUpdate public IPv4 A records for domain + subdomains
    + +

    Scripts

    + + +

    After changing secrets

    +

    Edit secrets/.env first, then:

    +

    cloudflare

    1. ./scripts/render-cloudflare-ddns-env.sh
    2. docker compose up -d cloudflare-ddns --force-recreate
    +

    usenet_servers

    1. ./scripts/configure-usenet.sh
    2. Test servers in SABnzbd UI
    +

    nzbgeek

    1. Update API key in Prowlarr UI
    +

    wireguard

    1. Edit wireguard/wg0.conf
    2. docker compose up -d gluetun --force-recreate
    3. or ./scripts/gluetun-endpoint-watch.sh
    +

    stack_env

    1. Edit .env
    2. docker compose up -d (+ build bonsai if backend changes)
    + +

    Fresh install

    +
      +
    1. cp .env.example .env; fill STACK_HOST, LAN_SUBNET, usenet, cloudflare keys
    2. Place wireguard/wg0.conf (Endpoint IP + # EndpointHost = hostname)
    3. ./scripts/apply-profile.sh mini|mid|gaming
    4. ./scripts/bootstrap-stack.sh
    5. One-time UI: Prowlarr apps, Jellyfin libraries, Forgejo/Open WebUI admin (demo user/pass)
    6. +
    + +

    Day-to-day on server

    +
      +
    1. cd /opt/stack && git pull
    2. docker compose up -d
    3. rebuild bonsai if Dockerfile/profile backend changed
    4. +
    + +

    Ansible

    + + +

    Never commit

    + + + +
    + + diff --git a/specification.json b/specification.json new file mode 100644 index 0000000..c356fd6 --- /dev/null +++ b/specification.json @@ -0,0 +1,413 @@ +{ + "meta": { + "name": "arr-stack", + "title": "Homelab media + local AI stack", + "version": "1.2.0", + "generated_at": "2026-07-23T02:54:38Z", + "generator": "scripts/generate-specification.py", + "repository": { + "forgejo": "http://192.168.8.123:3002/tim/stack", + "clone_ssh": "ssh://git@192.168.8.123:2222/tim/stack.git", + "default_branch": "main" + }, + "primary_host": { + "hostname": "stack", + "lan_ip": "192.168.8.123", + "user": "tim", + "os": "Ubuntu Server 24.04", + "hardware": "AMD Ryzen 7 5800H + Radeon Graphics (shared RAM), ~28–32 GB RAM", + "stack_root": "/opt/stack", + "media_root": "/storage" + }, + "demo_credentials": { + "username": "tim", + "password": "asdfasdf", + "note": "Tech demo only; used for local UIs when configured that way" + }, + "docs_for_humans": [ + "specification.html", + "quickstart.md", + "README.md" + ], + "docs_for_agents": [ + "specification.json", + "AGENTS.md", + "quickstart.md", + "profiles/*.env", + "docker-compose.yml" + ], + "never_commit": [ + ".env", + "secrets/.env", + "wireguard/wg0.conf", + "config/", + "models/**/*.gguf", + "data/" + ] + }, + "architecture": { + "summary": "Docker Compose stack combining VPN-locked torrents (*arr + qBittorrent via gluetun), Usenet (SABnzbd), media (Jellyfin), local LLM inference (Prism llama-server + Open WebUI), Forgejo git, OpenHands agent, Caddy LAN landing page, and Cloudflare DDNS.", + "networks": { + "arr-net": "Bridge network for most services", + "gluetun_namespace": "qbittorrent uses network_mode: service:gluetun (VPN killswitch)", + "host_network": [ + "cloudflare-ddns" + ] + }, + "data_layout": { + "CONFIG_DIR": "App configs (default /opt/stack/config on server)", + "DATA_DIR": "Shared media tree (default /storage on server)", + "paths": { + "media": "/data/media/{movies,tv,music}", + "torrents": "/data/torrents/{movies,tv,music}", + "usenet": "/data/usenet/incomplete and /data/usenet/complete/{movies,tv,music}", + "models": "models/bonsai/*.gguf mounted read-only at /models in bonsai", + "workspace": "AI_WORKSPACE_DIR for Open WebUI + OpenHands" + } + }, + "security_model": { + "torrents": "Only via WireGuard through gluetun; no BitTorrent port on host", + "usenet": "Direct SSL to provider (not through gluetun)", + "lan_firewall": "UFW allow service ports from LAN_SUBNET only", + "landing_and_uis": "Plain HTTP on LAN (no TLS certs yet)" + } + }, + "hardware_profiles": { + "how_to_apply": "./scripts/apply-profile.sh mini|mid|gaming", + "files": { + "mini": "profiles/mini.env", + "mid": "profiles/mid.env", + "gaming": "profiles/gaming.env" + }, + "profiles": { + "mini": { + "hardware": "Beelink / SER5-class mini PC", + "inference": "cpu", + "ngl": 0, + "default_ctx": 32768, + "threads": 12, + "default_model": "Ternary-Bonsai-27B-Q2_0.gguf", + "model_repo": "prism-ml/Ternary-Bonsai-27B-gguf", + "notes": "CPU preferred on APU; do not force NGL in docker-compose.override.yml" + }, + "mid": { + "hardware": "~8GB AMD discrete GPU, ~16GB system RAM", + "inference": "vulkan", + "ngl": 99, + "default_ctx": 8192, + "threads": 8, + "kv4": true, + "default_model": "Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf", + "model_repo": "mradermacher/Qwen3.5-9B-abliterated-v2-MAX-GGUF", + "notes": "Fits VRAM; use KV4 for memory" + }, + "gaming": { + "hardware": "~16GB AMD GPU, ~64GB system RAM", + "inference": "vulkan", + "ngl": 99, + "default_ctx": 32768, + "threads": 16, + "default_model": "Ternary-Bonsai-27B-Q2_0.gguf", + "also_download": [ + "Qwen3.5-9B Q4_K_M" + ], + "notes": "Full Vulkan + large context" + } + }, + "models": { + "bonsai27": { + "file": "Ternary-Bonsai-27B-Q2_0.gguf", + "repo": "prism-ml/Ternary-Bonsai-27B-gguf", + "runtime": "PrismML llama.cpp fork (custom Q2_0)", + "size_approx": "~7.2 GB" + }, + "qwen9_abliterated_q4km": { + "file": "Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf", + "repo": "mradermacher/Qwen3.5-9B-abliterated-v2-MAX-GGUF", + "runtime": "Same Prism llama-server image (standard GGUF)", + "size_approx": "~5.6 GB" + } + } + }, + "services": [ + { + "id": "landing", + "name": "Landing (Caddy)", + "image": "caddy:2-alpine", + "port": 80, + "url": "http://HOST/", + "purpose": "Dark-mode LAN resource map", + "network": "arr-net", + "category": "gateway" + }, + { + "id": "gluetun", + "name": "gluetun", + "image": "qmcgaw/gluetun:v3", + "port": null, + "published_ports": [ + "8080:8080 (qBittorrent WebUI)" + ], + "purpose": "WireGuard VPN + killswitch", + "network": "arr-net", + "category": "vpn", + "config": "wireguard/wg0.conf" + }, + { + "id": "qbittorrent", + "name": "qBittorrent", + "image": "lscr.io/linuxserver/qbittorrent:latest", + "port": 8080, + "url": "http://HOST:8080/", + "purpose": "Torrent client (VPN-only via gluetun)", + "network": "service:gluetun", + "category": "download" + }, + { + "id": "sabnzbd", + "name": "SABnzbd", + "image": "lscr.io/linuxserver/sabnzbd:latest", + "port": 8085, + "url": "http://HOST:8085/", + "purpose": "Usenet NZB downloader", + "network": "arr-net", + "category": "download" + }, + { + "id": "prowlarr", + "name": "Prowlarr", + "image": "lscr.io/linuxserver/prowlarr:latest", + "port": 9696, + "url": "http://HOST:9696/", + "purpose": "Indexer manager (torrents + NZB)", + "network": "arr-net", + "category": "download" + }, + { + "id": "flaresolverr", + "name": "FlareSolverr", + "image": "ghcr.io/flaresolverr/flaresolverr:latest", + "port": 8191, + "url": "http://flaresolverr:8191 (internal)", + "purpose": "Cloudflare JS challenge proxy for Prowlarr", + "network": "arr-net", + "category": "download", + "published": false + }, + { + "id": "sonarr", + "name": "Sonarr", + "image": "lscr.io/linuxserver/sonarr:latest", + "port": 8989, + "url": "http://HOST:8989/", + "purpose": "TV automation", + "network": "arr-net", + "category": "media" + }, + { + "id": "radarr", + "name": "Radarr", + "image": "lscr.io/linuxserver/radarr:latest", + "port": 7878, + "url": "http://HOST:7878/", + "purpose": "Movie automation", + "network": "arr-net", + "category": "media" + }, + { + "id": "lidarr", + "name": "Lidarr", + "image": "lscr.io/linuxserver/lidarr:latest", + "port": 8686, + "url": "http://HOST:8686/", + "purpose": "Music automation", + "network": "arr-net", + "category": "media" + }, + { + "id": "bazarr", + "name": "Bazarr", + "image": "lscr.io/linuxserver/bazarr:latest", + "port": 6767, + "url": "http://HOST:6767/", + "purpose": "Subtitles", + "network": "arr-net", + "category": "media" + }, + { + "id": "jellyfin", + "name": "Jellyfin", + "image": "lscr.io/linuxserver/jellyfin:latest", + "port": 8096, + "url": "http://HOST:8096/", + "purpose": "Media server / playback", + "network": "arr-net", + "category": "media" + }, + { + "id": "bonsai", + "name": "Bonsai (llama-server)", + "image": "arr-stack/bonsai:local (ai/Dockerfile)", + "port": 8081, + "url": "http://HOST:8081/v1", + "purpose": "Local LLM OpenAI-compatible API (Prism build)", + "network": "arr-net", + "category": "ai" + }, + { + "id": "open-webui", + "name": "Open WebUI", + "image": "ghcr.io/open-webui/open-webui:main", + "port": 3000, + "url": "http://HOST:3000/", + "purpose": "Chat UI + RAG + tools", + "network": "arr-net", + "category": "ai", + "llm_base": "http://bonsai:8080/v1" + }, + { + "id": "searxng", + "name": "SearXNG", + "image": "docker.io/searxng/searxng:latest", + "port": 8888, + "url": "http://127.0.0.1:8888/ (localhost only)", + "purpose": "Private search for Open WebUI RAG", + "network": "arr-net", + "category": "ai", + "published": "loopback" + }, + { + "id": "openhands", + "name": "OpenHands", + "image": "ghcr.io/all-hands-ai/openhands:0.54", + "port": 3001, + "url": "http://HOST:3001/", + "purpose": "AI coding agent (always deployed)", + "network": "arr-net", + "category": "ai", + "note": "No Forgejo integration in UI — clone repos into workspace" + }, + { + "id": "forgejo", + "name": "Forgejo", + "image": "codeberg.org/forgejo/forgejo:11", + "port": 3002, + "url": "http://HOST:3002/", + "ssh_port": 2222, + "purpose": "Self-hosted Git", + "network": "arr-net", + "category": "code" + }, + { + "id": "cloudflare-ddns", + "name": "Cloudflare DDNS", + "image": "timothyjmiller/cloudflare-ddns:latest", + "port": null, + "url": null, + "purpose": "Update public IPv4 A records for domain + subdomains", + "network": "host", + "category": "dns", + "domain": "devopshomelab.com", + "subdomains": [ + "mc", + "minecraft", + "vpn", + "www", + "forgejo", + "jellyfin" + ] + } + ], + "scripts": { + "generate-specification.py": "Regenerate specification.json + specification.html (this file)", + "apply-profile.sh": "Merge profiles/{mini,mid,gaming}.env into .env", + "bootstrap-stack.sh": "Fresh bring-up: dirs, models, compose up all, heal gluetun, usenet, cloudflare", + "download-models.sh": "Download Bonsai 27B and/or Qwen 9B GGUF into models/bonsai", + "download-bonsai-model.sh": "Wrapper → download-models.sh", + "configure-usenet.sh": "SABnzbd folders + Newshosting/Tweaknews from secrets/.env", + "render-cloudflare-ddns-env.sh": "Build config/cloudflare-ddns/ddns.env from secrets", + "gluetun-endpoint-watch.sh": "If gluetun unhealthy: dig WG host, update Endpoint, recreate gluetun+qbit" + }, + "ansible": { + "entry": "ansible/site.yml", + "inventory": "ansible/inventory/hosts.ini", + "roles": [ + "common", + "docker", + "amd_vulkan", + "firewall", + "stack", + "users" + ], + "stack_deploy_method": "git (default) or rsync", + "server_repo": "/opt/stack tracks Forgejo via git pull", + "timers": [ + "arr-stack.service", + "gluetun-endpoint-watch.timer" + ] + }, + "workflows": { + "fresh_install": [ + "cp .env.example .env; fill STACK_HOST, LAN_SUBNET, usenet, cloudflare keys", + "Place wireguard/wg0.conf (Endpoint IP + # EndpointHost = hostname)", + "./scripts/apply-profile.sh mini|mid|gaming", + "./scripts/bootstrap-stack.sh", + "One-time UI: Prowlarr apps, Jellyfin libraries, Forgejo/Open WebUI admin (demo user/pass)" + ], + "day_to_day_server": [ + "cd /opt/stack && git pull", + "docker compose up -d", + "rebuild bonsai if Dockerfile/profile backend changed" + ], + "after_secrets_change": { + "cloudflare": [ + "./scripts/render-cloudflare-ddns-env.sh", + "docker compose up -d cloudflare-ddns --force-recreate" + ], + "usenet_servers": [ + "./scripts/configure-usenet.sh", + "Test servers in SABnzbd UI" + ], + "nzbgeek": [ + "Update API key in Prowlarr UI" + ], + "wireguard": [ + "Edit wireguard/wg0.conf", + "docker compose up -d gluetun --force-recreate", + "or ./scripts/gluetun-endpoint-watch.sh" + ], + "stack_env": [ + "Edit .env", + "docker compose up -d (+ build bonsai if backend changes)" + ] + }, + "switch_profile": [ + "./scripts/apply-profile.sh mid", + "docker compose build bonsai && docker compose up -d" + ], + "agent_git": [ + "Commit meaningful changes with complete sentences", + "Push to origin main (Forgejo)", + "Never commit secrets", + "After architecture changes: run generate-specification.py and commit both outputs" + ] + }, + "compose": { + "file": "docker-compose.yml", + "override": "docker-compose.override.yml", + "always_deploy_all": true, + "note": "OpenHands is not behind a compose profile; full stack always starts", + "override_rules": [ + "Do not put group_add names (video/render) — use host GIDs or 0666 /dev/dri", + "Do not force bonsai NGL in override on mini (overrides .env)", + "mini: jellyfin /dev/dri only; mid/gaming: add bonsai devices for Vulkan" + ] + }, + "env_and_secrets": { + "stack_env": ".env (from .env.example) — ports, paths, AI, bootstrap flags, demo login", + "secrets_env": "secrets/.env — usenet + cloudflare API (gitignored)", + "approx_env_keys": 80, + "demo_user": "tim", + "demo_pass": "asdfasdf" + } +}