stack/specification.html
tim 1a6f915f04 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.
2026-07-22 19:54:42 -07:00

194 lines
12 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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), ~2832 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 &amp;&amp; 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>