{ "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" } }