Add paired specification.json and specification.html generator
Single SPEC source regenerates machine-readable JSON for agents and dark HTML for humans so architecture survives lost chat sessions.
This commit is contained in:
parent
0108868a2c
commit
1a6f915f04
5 changed files with 1265 additions and 0 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -1,5 +1,20 @@
|
||||||
# Agent notes (arr-stack)
|
# 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
|
## Git
|
||||||
|
|
||||||
- Remote: `origin` → `ssh://git@192.168.8.123:2222/tim/stack.git` (Forgejo `tim/stack`)
|
- Remote: `origin` → `ssh://git@192.168.8.123:2222/tim/stack.git` (Forgejo `tim/stack`)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
Homelab host: **`192.168.8.123`** (SER5, Ubuntu 24.04)
|
Homelab host: **`192.168.8.123`** (SER5, Ubuntu 24.04)
|
||||||
Stack: **`/opt/stack`** · Media: **`/storage`** · User: **`tim`**
|
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)
|
## Fresh install / tech demo (max automation)
|
||||||
|
|
|
||||||
640
scripts/generate-specification.py
Executable file
640
scripts/generate-specification.py
Executable file
|
|
@ -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(
|
||||||
|
"<tr>"
|
||||||
|
f"<td><code>{_esc(s['id'])}</code></td>"
|
||||||
|
f"<td>{_esc(s['name'])}</td>"
|
||||||
|
f"<td>{_esc(s.get('category', ''))}</td>"
|
||||||
|
f"<td>{_esc(port_s)}</td>"
|
||||||
|
f"<td><a href=\"{_esc(url) if url.startswith('http') else '#'}\">{_esc(url)}</a></td>"
|
||||||
|
f"<td>{_esc(s['purpose'])}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
return "\n".join(rows)
|
||||||
|
|
||||||
|
def profile_cards() -> str:
|
||||||
|
cards = []
|
||||||
|
for name, p in profiles.items():
|
||||||
|
cards.append(
|
||||||
|
f"""
|
||||||
|
<article class="card">
|
||||||
|
<h3>{_esc(name)}</h3>
|
||||||
|
<p class="muted">{_esc(p['hardware'])}</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Inference:</strong> {_esc(p['inference'])} (ngl={_esc(p['ngl'])})</li>
|
||||||
|
<li><strong>Context:</strong> {_esc(p['default_ctx'])}</li>
|
||||||
|
<li><strong>Model:</strong> <code>{_esc(p['default_model'])}</code></li>
|
||||||
|
<li>{_esc(p.get('notes', ''))}</li>
|
||||||
|
</ul>
|
||||||
|
</article>"""
|
||||||
|
)
|
||||||
|
return "\n".join(cards)
|
||||||
|
|
||||||
|
def script_list() -> str:
|
||||||
|
return "\n".join(
|
||||||
|
f"<li><code>{_esc(k)}</code> — {_esc(v)}</li>" for k, v in scripts.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
def workflow_secrets() -> str:
|
||||||
|
parts = []
|
||||||
|
for k, steps in workflows["after_secrets_change"].items():
|
||||||
|
items = "".join(f"<li><code>{_esc(s)}</code></li>" for s in steps)
|
||||||
|
parts.append(f"<h4>{_esc(k)}</h4><ol>{items}</ol>")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{_esc(m['title'])} — specification</title>
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--bg: #0c0e12; --elev: #141820; --card: #1a1f2a; --border: #2a3344;
|
||||||
|
--text: #e8ecf4; --muted: #8b95a8; --accent: #6ea8fe; --ok: #7dd3a0;
|
||||||
|
--font: "Segoe UI", system-ui, sans-serif;
|
||||||
|
--mono: ui-monospace, "Cascadia Code", Menlo, monospace;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0; font-family: var(--font); background: var(--bg); color: var(--text);
|
||||||
|
line-height: 1.55; padding: 2rem 1.25rem 4rem;
|
||||||
|
background-image: radial-gradient(ellipse 80% 40% at 50% -10%, rgba(110,168,254,.12), transparent);
|
||||||
|
}}
|
||||||
|
.wrap {{ max-width: 980px; margin: 0 auto; }}
|
||||||
|
h1 {{ font-size: 1.75rem; letter-spacing: -0.02em; margin: 0 0 .35rem; }}
|
||||||
|
h2 {{ font-size: 1.2rem; margin: 2rem 0 .75rem; border-bottom: 1px solid var(--border); padding-bottom: .4rem; }}
|
||||||
|
h3 {{ font-size: 1.05rem; margin: 0 0 .4rem; }}
|
||||||
|
h4 {{ font-size: .95rem; margin: 1rem 0 .35rem; color: var(--accent); }}
|
||||||
|
p, li {{ color: var(--muted); }}
|
||||||
|
.lead {{ color: var(--text); font-size: 1.05rem; }}
|
||||||
|
.meta {{
|
||||||
|
display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0 1.5rem;
|
||||||
|
}}
|
||||||
|
.pill {{
|
||||||
|
font-family: var(--mono); font-size: .78rem; padding: .3rem .65rem;
|
||||||
|
border: 1px solid var(--border); border-radius: 999px; background: var(--elev); color: var(--accent);
|
||||||
|
}}
|
||||||
|
.banner {{
|
||||||
|
background: var(--elev); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
padding: 1rem 1.15rem; margin-bottom: 1.5rem;
|
||||||
|
}}
|
||||||
|
.banner strong {{ color: var(--ok); }}
|
||||||
|
code {{ font-family: var(--mono); font-size: .88em; background: var(--card); padding: .1rem .35rem; border-radius: 4px; color: var(--accent); }}
|
||||||
|
a {{ color: var(--accent); }}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; font-size: .88rem; margin: .75rem 0 1.25rem; }}
|
||||||
|
th, td {{ text-align: left; padding: .5rem .55rem; border-bottom: 1px solid var(--border); vertical-align: top; }}
|
||||||
|
th {{ color: var(--text); font-weight: 600; background: var(--elev); }}
|
||||||
|
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .75rem; }}
|
||||||
|
.card {{
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1rem;
|
||||||
|
}}
|
||||||
|
.card ul {{ margin: .4rem 0 0; padding-left: 1.1rem; }}
|
||||||
|
.card li {{ margin: .25rem 0; }}
|
||||||
|
.muted {{ color: var(--muted); font-size: .9rem; }}
|
||||||
|
footer {{ margin-top: 2.5rem; font-size: .8rem; color: #5c667a; text-align: center; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>{_esc(m['title'])}</h1>
|
||||||
|
<p class="lead">{_esc(SPEC['architecture']['summary'])}</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="pill">spec v{_esc(m['version'])}</span>
|
||||||
|
<span class="pill">generated {_esc(m['generated_at'])}</span>
|
||||||
|
<span class="pill">host {_esc(host)}</span>
|
||||||
|
<span class="pill">stack {_esc(m['primary_host']['stack_root'])}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="banner">
|
||||||
|
<strong>Source of truth:</strong> edit <code>scripts/generate-specification.py</code> (the <code>SPEC</code> dict),
|
||||||
|
then run <code>./scripts/generate-specification.py</code>. That rewrites both
|
||||||
|
<code>specification.json</code> (agents) and <code>specification.html</code> (you) so they never drift.
|
||||||
|
Agents should prefer <code>specification.json</code> + <code>AGENTS.md</code>; humans use this HTML and <code>quickstart.md</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Primary host</h2>
|
||||||
|
<ul>
|
||||||
|
<li>LAN: <code>http://{_esc(host)}/</code> (Caddy landing)</li>
|
||||||
|
<li>User: <code>{_esc(m['primary_host']['user'])}</code> · OS: {_esc(m['primary_host']['os'])}</li>
|
||||||
|
<li>Hardware: {_esc(m['primary_host']['hardware'])}</li>
|
||||||
|
<li>Git: <a href="{_esc(m['repository']['forgejo'])}">{_esc(m['repository']['forgejo'])}</a></li>
|
||||||
|
<li>Demo login: <code>{_esc(m['demo_credentials']['username'])}</code> / <code>{_esc(m['demo_credentials']['password'])}</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Hardware profiles</h2>
|
||||||
|
<p class="muted">Apply with <code>./scripts/apply-profile.sh mini|mid|gaming</code></p>
|
||||||
|
<div class="grid">
|
||||||
|
{profile_cards()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Services</h2>
|
||||||
|
<p class="muted">All services deploy by default (<code>docker compose up -d</code>). Replace HOST with {_esc(host)}. Links use plain <code>http://</code> (no TLS).</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Name</th><th>Category</th><th>Port</th><th>URL</th><th>Purpose</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{svc_rows()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Scripts</h2>
|
||||||
|
<ul>
|
||||||
|
{script_list()}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>After changing secrets</h2>
|
||||||
|
<p class="muted">Edit <code>secrets/.env</code> first, then:</p>
|
||||||
|
{workflow_secrets()}
|
||||||
|
|
||||||
|
<h2>Fresh install</h2>
|
||||||
|
<ol>
|
||||||
|
{''.join(f'<li><code>{_esc(s)}</code></li>' for s in workflows['fresh_install'])}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Day-to-day on server</h2>
|
||||||
|
<ol>
|
||||||
|
{''.join(f'<li><code>{_esc(s)}</code></li>' for s in workflows['day_to_day_server'])}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Ansible</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Entry: <code>{_esc(SPEC['ansible']['entry'])}</code></li>
|
||||||
|
<li>Roles: {', '.join(f'<code>{_esc(r)}</code>' for r in SPEC['ansible']['roles'])}</li>
|
||||||
|
<li>Deploy method: {_esc(SPEC['ansible']['stack_deploy_method'])}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Never commit</h2>
|
||||||
|
<ul>
|
||||||
|
{''.join(f'<li><code>{_esc(x)}</code></li>' for x in m['never_commit'])}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Generated by scripts/generate-specification.py · paired with specification.json · arr-stack
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
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()
|
||||||
194
specification.html
Normal file
194
specification.html
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Homelab media + local AI stack — specification</title>
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0c0e12; --elev: #141820; --card: #1a1f2a; --border: #2a3344;
|
||||||
|
--text: #e8ecf4; --muted: #8b95a8; --accent: #6ea8fe; --ok: #7dd3a0;
|
||||||
|
--font: "Segoe UI", system-ui, sans-serif;
|
||||||
|
--mono: ui-monospace, "Cascadia Code", Menlo, monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; font-family: var(--font); background: var(--bg); color: var(--text);
|
||||||
|
line-height: 1.55; padding: 2rem 1.25rem 4rem;
|
||||||
|
background-image: radial-gradient(ellipse 80% 40% at 50% -10%, rgba(110,168,254,.12), transparent);
|
||||||
|
}
|
||||||
|
.wrap { max-width: 980px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 1.75rem; letter-spacing: -0.02em; margin: 0 0 .35rem; }
|
||||||
|
h2 { font-size: 1.2rem; margin: 2rem 0 .75rem; border-bottom: 1px solid var(--border); padding-bottom: .4rem; }
|
||||||
|
h3 { font-size: 1.05rem; margin: 0 0 .4rem; }
|
||||||
|
h4 { font-size: .95rem; margin: 1rem 0 .35rem; color: var(--accent); }
|
||||||
|
p, li { color: var(--muted); }
|
||||||
|
.lead { color: var(--text); font-size: 1.05rem; }
|
||||||
|
.meta {
|
||||||
|
display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
font-family: var(--mono); font-size: .78rem; padding: .3rem .65rem;
|
||||||
|
border: 1px solid var(--border); border-radius: 999px; background: var(--elev); color: var(--accent);
|
||||||
|
}
|
||||||
|
.banner {
|
||||||
|
background: var(--elev); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
padding: 1rem 1.15rem; margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.banner strong { color: var(--ok); }
|
||||||
|
code { font-family: var(--mono); font-size: .88em; background: var(--card); padding: .1rem .35rem; border-radius: 4px; color: var(--accent); }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: .88rem; margin: .75rem 0 1.25rem; }
|
||||||
|
th, td { text-align: left; padding: .5rem .55rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
th { color: var(--text); font-weight: 600; background: var(--elev); }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .75rem; }
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1rem;
|
||||||
|
}
|
||||||
|
.card ul { margin: .4rem 0 0; padding-left: 1.1rem; }
|
||||||
|
.card li { margin: .25rem 0; }
|
||||||
|
.muted { color: var(--muted); font-size: .9rem; }
|
||||||
|
footer { margin-top: 2.5rem; font-size: .8rem; color: #5c667a; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Homelab media + local AI stack</h1>
|
||||||
|
<p class="lead">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.</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="pill">spec v1.2.0</span>
|
||||||
|
<span class="pill">generated 2026-07-23T02:54:38Z</span>
|
||||||
|
<span class="pill">host 192.168.8.123</span>
|
||||||
|
<span class="pill">stack /opt/stack</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="banner">
|
||||||
|
<strong>Source of truth:</strong> edit <code>scripts/generate-specification.py</code> (the <code>SPEC</code> dict),
|
||||||
|
then run <code>./scripts/generate-specification.py</code>. That rewrites both
|
||||||
|
<code>specification.json</code> (agents) and <code>specification.html</code> (you) so they never drift.
|
||||||
|
Agents should prefer <code>specification.json</code> + <code>AGENTS.md</code>; humans use this HTML and <code>quickstart.md</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Primary host</h2>
|
||||||
|
<ul>
|
||||||
|
<li>LAN: <code>http://192.168.8.123/</code> (Caddy landing)</li>
|
||||||
|
<li>User: <code>tim</code> · OS: Ubuntu Server 24.04</li>
|
||||||
|
<li>Hardware: AMD Ryzen 7 5800H + Radeon Graphics (shared RAM), ~28–32 GB RAM</li>
|
||||||
|
<li>Git: <a href="http://192.168.8.123:3002/tim/stack">http://192.168.8.123:3002/tim/stack</a></li>
|
||||||
|
<li>Demo login: <code>tim</code> / <code>asdfasdf</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Hardware profiles</h2>
|
||||||
|
<p class="muted">Apply with <code>./scripts/apply-profile.sh mini|mid|gaming</code></p>
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h3>mini</h3>
|
||||||
|
<p class="muted">Beelink / SER5-class mini PC</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Inference:</strong> cpu (ngl=0)</li>
|
||||||
|
<li><strong>Context:</strong> 32768</li>
|
||||||
|
<li><strong>Model:</strong> <code>Ternary-Bonsai-27B-Q2_0.gguf</code></li>
|
||||||
|
<li>CPU preferred on APU; do not force NGL in docker-compose.override.yml</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h3>mid</h3>
|
||||||
|
<p class="muted">~8GB AMD discrete GPU, ~16GB system RAM</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Inference:</strong> vulkan (ngl=99)</li>
|
||||||
|
<li><strong>Context:</strong> 8192</li>
|
||||||
|
<li><strong>Model:</strong> <code>Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf</code></li>
|
||||||
|
<li>Fits VRAM; use KV4 for memory</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h3>gaming</h3>
|
||||||
|
<p class="muted">~16GB AMD GPU, ~64GB system RAM</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Inference:</strong> vulkan (ngl=99)</li>
|
||||||
|
<li><strong>Context:</strong> 32768</li>
|
||||||
|
<li><strong>Model:</strong> <code>Ternary-Bonsai-27B-Q2_0.gguf</code></li>
|
||||||
|
<li>Full Vulkan + large context</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Services</h2>
|
||||||
|
<p class="muted">All services deploy by default (<code>docker compose up -d</code>). Replace HOST with 192.168.8.123. Links use plain <code>http://</code> (no TLS).</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Name</th><th>Category</th><th>Port</th><th>URL</th><th>Purpose</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>landing</code></td><td>Landing (Caddy)</td><td>gateway</td><td>80</td><td><a href="http://192.168.8.123/">http://192.168.8.123/</a></td><td>Dark-mode LAN resource map</td></tr>
|
||||||
|
<tr><td><code>gluetun</code></td><td>gluetun</td><td>vpn</td><td>—</td><td><a href="#">—</a></td><td>WireGuard VPN + killswitch</td></tr>
|
||||||
|
<tr><td><code>qbittorrent</code></td><td>qBittorrent</td><td>download</td><td>8080</td><td><a href="http://192.168.8.123:8080/">http://192.168.8.123:8080/</a></td><td>Torrent client (VPN-only via gluetun)</td></tr>
|
||||||
|
<tr><td><code>sabnzbd</code></td><td>SABnzbd</td><td>download</td><td>8085</td><td><a href="http://192.168.8.123:8085/">http://192.168.8.123:8085/</a></td><td>Usenet NZB downloader</td></tr>
|
||||||
|
<tr><td><code>prowlarr</code></td><td>Prowlarr</td><td>download</td><td>9696</td><td><a href="http://192.168.8.123:9696/">http://192.168.8.123:9696/</a></td><td>Indexer manager (torrents + NZB)</td></tr>
|
||||||
|
<tr><td><code>flaresolverr</code></td><td>FlareSolverr</td><td>download</td><td>8191</td><td><a href="http://flaresolverr:8191 (internal)">http://flaresolverr:8191 (internal)</a></td><td>Cloudflare JS challenge proxy for Prowlarr</td></tr>
|
||||||
|
<tr><td><code>sonarr</code></td><td>Sonarr</td><td>media</td><td>8989</td><td><a href="http://192.168.8.123:8989/">http://192.168.8.123:8989/</a></td><td>TV automation</td></tr>
|
||||||
|
<tr><td><code>radarr</code></td><td>Radarr</td><td>media</td><td>7878</td><td><a href="http://192.168.8.123:7878/">http://192.168.8.123:7878/</a></td><td>Movie automation</td></tr>
|
||||||
|
<tr><td><code>lidarr</code></td><td>Lidarr</td><td>media</td><td>8686</td><td><a href="http://192.168.8.123:8686/">http://192.168.8.123:8686/</a></td><td>Music automation</td></tr>
|
||||||
|
<tr><td><code>bazarr</code></td><td>Bazarr</td><td>media</td><td>6767</td><td><a href="http://192.168.8.123:6767/">http://192.168.8.123:6767/</a></td><td>Subtitles</td></tr>
|
||||||
|
<tr><td><code>jellyfin</code></td><td>Jellyfin</td><td>media</td><td>8096</td><td><a href="http://192.168.8.123:8096/">http://192.168.8.123:8096/</a></td><td>Media server / playback</td></tr>
|
||||||
|
<tr><td><code>bonsai</code></td><td>Bonsai (llama-server)</td><td>ai</td><td>8081</td><td><a href="http://192.168.8.123:8081/v1">http://192.168.8.123:8081/v1</a></td><td>Local LLM OpenAI-compatible API (Prism build)</td></tr>
|
||||||
|
<tr><td><code>open-webui</code></td><td>Open WebUI</td><td>ai</td><td>3000</td><td><a href="http://192.168.8.123:3000/">http://192.168.8.123:3000/</a></td><td>Chat UI + RAG + tools</td></tr>
|
||||||
|
<tr><td><code>searxng</code></td><td>SearXNG</td><td>ai</td><td>8888</td><td><a href="http://127.0.0.1:8888/ (localhost only)">http://127.0.0.1:8888/ (localhost only)</a></td><td>Private search for Open WebUI RAG</td></tr>
|
||||||
|
<tr><td><code>openhands</code></td><td>OpenHands</td><td>ai</td><td>3001</td><td><a href="http://192.168.8.123:3001/">http://192.168.8.123:3001/</a></td><td>AI coding agent (always deployed)</td></tr>
|
||||||
|
<tr><td><code>forgejo</code></td><td>Forgejo</td><td>code</td><td>3002</td><td><a href="http://192.168.8.123:3002/">http://192.168.8.123:3002/</a></td><td>Self-hosted Git</td></tr>
|
||||||
|
<tr><td><code>cloudflare-ddns</code></td><td>Cloudflare DDNS</td><td>dns</td><td>—</td><td><a href="#">—</a></td><td>Update public IPv4 A records for domain + subdomains</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Scripts</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>generate-specification.py</code> — Regenerate specification.json + specification.html (this file)</li>
|
||||||
|
<li><code>apply-profile.sh</code> — Merge profiles/{mini,mid,gaming}.env into .env</li>
|
||||||
|
<li><code>bootstrap-stack.sh</code> — Fresh bring-up: dirs, models, compose up all, heal gluetun, usenet, cloudflare</li>
|
||||||
|
<li><code>download-models.sh</code> — Download Bonsai 27B and/or Qwen 9B GGUF into models/bonsai</li>
|
||||||
|
<li><code>download-bonsai-model.sh</code> — Wrapper → download-models.sh</li>
|
||||||
|
<li><code>configure-usenet.sh</code> — SABnzbd folders + Newshosting/Tweaknews from secrets/.env</li>
|
||||||
|
<li><code>render-cloudflare-ddns-env.sh</code> — Build config/cloudflare-ddns/ddns.env from secrets</li>
|
||||||
|
<li><code>gluetun-endpoint-watch.sh</code> — If gluetun unhealthy: dig WG host, update Endpoint, recreate gluetun+qbit</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>After changing secrets</h2>
|
||||||
|
<p class="muted">Edit <code>secrets/.env</code> first, then:</p>
|
||||||
|
<h4>cloudflare</h4><ol><li><code>./scripts/render-cloudflare-ddns-env.sh</code></li><li><code>docker compose up -d cloudflare-ddns --force-recreate</code></li></ol>
|
||||||
|
<h4>usenet_servers</h4><ol><li><code>./scripts/configure-usenet.sh</code></li><li><code>Test servers in SABnzbd UI</code></li></ol>
|
||||||
|
<h4>nzbgeek</h4><ol><li><code>Update API key in Prowlarr UI</code></li></ol>
|
||||||
|
<h4>wireguard</h4><ol><li><code>Edit wireguard/wg0.conf</code></li><li><code>docker compose up -d gluetun --force-recreate</code></li><li><code>or ./scripts/gluetun-endpoint-watch.sh</code></li></ol>
|
||||||
|
<h4>stack_env</h4><ol><li><code>Edit .env</code></li><li><code>docker compose up -d (+ build bonsai if backend changes)</code></li></ol>
|
||||||
|
|
||||||
|
<h2>Fresh install</h2>
|
||||||
|
<ol>
|
||||||
|
<li><code>cp .env.example .env; fill STACK_HOST, LAN_SUBNET, usenet, cloudflare keys</code></li><li><code>Place wireguard/wg0.conf (Endpoint IP + # EndpointHost = hostname)</code></li><li><code>./scripts/apply-profile.sh mini|mid|gaming</code></li><li><code>./scripts/bootstrap-stack.sh</code></li><li><code>One-time UI: Prowlarr apps, Jellyfin libraries, Forgejo/Open WebUI admin (demo user/pass)</code></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Day-to-day on server</h2>
|
||||||
|
<ol>
|
||||||
|
<li><code>cd /opt/stack && git pull</code></li><li><code>docker compose up -d</code></li><li><code>rebuild bonsai if Dockerfile/profile backend changed</code></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Ansible</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Entry: <code>ansible/site.yml</code></li>
|
||||||
|
<li>Roles: <code>common</code>, <code>docker</code>, <code>amd_vulkan</code>, <code>firewall</code>, <code>stack</code>, <code>users</code></li>
|
||||||
|
<li>Deploy method: git (default) or rsync</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Never commit</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>.env</code></li><li><code>secrets/.env</code></li><li><code>wireguard/wg0.conf</code></li><li><code>config/</code></li><li><code>models/**/*.gguf</code></li><li><code>data/</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Generated by scripts/generate-specification.py · paired with specification.json · arr-stack
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
413
specification.json
Normal file
413
specification.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue