Initial commit: arr-stack homelab + local AI

Docker Compose media stack (gluetun, *arr, Jellyfin), Ternary-Bonsai AI
(Open WebUI, Forgejo, optional OpenHands), and Ansible bootstrap for the
SER5 Ubuntu host.
This commit is contained in:
tim 2026-07-22 06:28:49 -07:00
commit 3645d1314c
33 changed files with 2673 additions and 0 deletions

74
.env.example Normal file
View file

@ -0,0 +1,74 @@
# Copy to .env and adjust. The compose file reads these values.
# --- user / group that owns your media (run `id` on the host) ---
PUID=1000
PGID=1000
# --- your timezone (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) ---
TZ=Europe/Berlin
# --- host paths (relative to this folder is fine) ---
CONFIG_DIR=./config
DATA_DIR=./data
# --- your LAN subnet, so the qBittorrent WebUI stays reachable through
# gluetun's firewall. If your machine is 192.168.1.50, use 192.168.1.0/24
LAN_SUBNET=192.168.1.0/24
# =============================================================================
# Local AI (Ternary-Bonsai-27B on SER5 PRO / Ryzen 7735HS)
# =============================================================================
# GGUF weights directory (populated by scripts/download-bonsai-model.sh)
BONSAI_MODELS_DIR=./models/bonsai
BONSAI_MODEL_PATH=/models/Ternary-Bonsai-27B-Q2_0.gguf
# cpu (default) or vulkan — rebuild image after changing:
# BONSAI_BACKEND=vulkan docker compose build --no-cache bonsai
BONSAI_BACKEND=cpu
# GPU layers: 0 = CPU only. With vulkan image + /dev/dri, try 99.
BONSAI_NGL=0
# Context size. 16384 is a solid default on 3264 GB RAM with the Arr stack.
# Raise for long docs / repo work; set BONSAI_KV4=1 if you run OOM.
BONSAI_CTX=16384
BONSAI_KV4=0
# Leave 0 for auto threads, or e.g. 12 on 16-thread 7735HS to leave headroom
BONSAI_THREADS=0
# Sampling (Prism 27B thinking-mode defaults)
BONSAI_TEMP=0.7
BONSAI_TOP_P=0.95
BONSAI_TOP_K=20
# Host port for llama-server API (OpenAI-compatible at /v1)
BONSAI_PORT=8081
# Open WebUI
OPEN_WEBUI_PORT=3000
OPENAI_API_KEY=sk-local-bonsai
# true = login required (recommended); false = open LAN access
WEBUI_AUTH=true
# Folder mounted read-only into Open WebUI as /workspace (RAG + file Q&A)
# Point this at your git projects, notes, docs, etc.
AI_WORKSPACE_DIR=./workspace
# SearXNG (web search for RAG) — published on localhost only
SEARXNG_PORT=8888
SEARXNG_SECRET=change-me-to-a-long-random-string
# OpenHands coding agent (docker compose --profile coding up -d)
OPENHANDS_PORT=3001
# LiteLLM-style id; after first start check http://host:8081/v1/models
OPENHANDS_MODEL=openai/local
# Forgejo — self-hosted Git (web + SSH clone)
FORGEJO_HTTP_PORT=3002
FORGEJO_SSH_PORT=2222
FORGEJO_DOMAIN=192.168.8.123
FORGEJO_ROOT_URL=http://192.168.8.123:3002/
# true after you create the admin account (blocks random signups)
FORGEJO_DISABLE_REGISTRATION=false

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
ansible/*.retry
ansible/.secrets/
config/
config/wg0.conf
data/
.env
.env.*
!.env.example
*.gguf
models/bonsai/*.gguf
models/bonsai/*.partial
.openhands-state/
# secrets / local runtime
**/wg0.conf
wireguard/wg0.conf
workspace/*
!workspace/README.md

323
README.md Normal file
View file

@ -0,0 +1,323 @@
# Arr Stack + Local AI
A dockerized media automation stack (VPN-locked torrents + Jellyfin) **plus** a
local AI stack built around
[Ternary-Bonsai-27B](https://huggingface.co/prism-ml/Ternary-Bonsai-27B-gguf)
for the **Beelink SER5 PRO** (Ryzen 7 7735HS + Radeon 680M).
## Services
| Service | URL | Purpose |
|-------------|------------------------|--------------------------------------|
| Jellyfin | http://host:8096 | Media server / playback |
| qBittorrent | http://host:8080 | Torrent client (VPN-only) |
| Prowlarr | http://host:9696 | Indexer manager |
| Sonarr | http://host:8989 | TV shows |
| Radarr | http://host:7878 | Movies |
| Bazarr | http://host:6767 | Subtitles |
| Lidarr | http://host:8686 | Music |
| **Forgejo** | http://host:3002 | Self-hosted Git (SSH :2222) |
| **Open WebUI** | http://host:3000 | Chat UI, RAG, tools, code interpreter |
| **Bonsai API** | http://host:8081/v1 | OpenAI-compatible llama-server |
| SearXNG | http://127.0.0.1:8888 | Private web search (for RAG) |
| OpenHands | http://host:3001 | Coding agent (`--profile coding`) |
---
## Media stack
### 1. Prepare
```bash
cd arr-stack
cp .env.example .env # then edit .env (PUID/PGID, TZ, LAN_SUBNET)
```
Drop your provider's WireGuard config into `wireguard/wg0.conf` and make the
two edits described in `wireguard/wg0.conf.example`:
1. **Remove IPv6 addresses** from `Address` / `DNS` lines.
2. **Endpoint must be an IP**, not a hostname (`dig +short your.endpoint.host`).
Create the data tree (already scaffolded here; on a fresh host run):
```bash
mkdir -p config data/media/{movies,tv,music} data/torrents/{movies,tv,music}
```
The layout uses a **single `/data` share** — this lets the Arrs hardlink
finished downloads into the media library instantly (no copy, no double disk
usage, torrent keeps seeding):
```
data/
├── media/ <- Jellyfin libraries
│ ├── movies/
│ ├── tv/
│ └── music/
└── torrents/ <- qBittorrent downloads here
├── movies/
├── tv/
└── music/
```
### 2. Start media
```bash
docker compose up -d
docker compose ps # gluetun must show (healthy)
docker logs -f gluetun # wait for "You are running on the VPN" style line
```
qBittorrent waits for gluetun to be healthy before starting.
### 3. Verify the lockdown (do this once)
```bash
# IP seen from inside qBittorrent — must be your VPN's IP, NOT your home IP:
docker exec qbittorrent curl -s ifconfig.me && echo
# Your real IP for comparison:
curl -s ifconfig.me && echo
# Killswitch test — kill the tunnel, traffic must die with it:
docker exec gluetun sh -c "ip link del tun0" 2>/dev/null
docker exec qbittorrent curl -s --max-time 8 ifconfig.me || echo "NO CONNECTIVITY (good)"
docker restart gluetun # bring the tunnel back
```
Belt-and-suspenders extra: in qBittorrent → Tools → Options → Advanced, set
**Network interface** to `tun0`. Even in a hypothetical gluetun failure,
qBittorrent itself would then refuse to use any other interface.
### 4. Wire it together
**qBittorrent** (http://host:8080 — default user `admin`, temporary password
is printed in `docker logs qbittorrent` on first start; change it):
- Options → Downloads → Default Save Path: `/data/torrents`
- Create categories `movies``/data/torrents/movies` and `tv``/data/torrents/tv`
**Prowlarr** (http://host:9696):
- Add your indexers under *Indexers*.
- *Settings → Apps*: add Sonarr (`http://sonarr:8989`), Radarr
(`http://radarr:7878`), each with its API key (found in the Arr under
*Settings → General*). Indexers sync automatically from then on.
**Sonarr & Radarr** — *Settings → Download Clients* → add qBittorrent:
- Host: `gluetun`, Port: `8080`, your qBittorrent credentials
- Category: `tv` (Sonarr) / `movies` (Radarr)
- *Settings → Media Management*: root folder `/data/media/tv` or
`/data/media/movies`; **enable hardlinks** (on by default with this layout)
**Bazarr**: point it at Sonarr/Radarr (`http://sonarr:8989` /
`http://radarr:7878` + API keys); paths `/data/media/tv`, `/data/media/movies`.
**Lidarr** (http://host:8686) — music, same patterns as Sonarr/Radarr:
- *Settings → Download Clients* → qBittorrent: Host `gluetun`, Port `8080`
- Category: `music` → qBittorrent save path `/data/torrents/music`
- *Settings → Media Management*: root folder `/data/media/music`; hardlinks on
- In **Prowlarr***Settings → Apps*: add Lidarr (`http://lidarr:8686` + API key)
**Jellyfin** (http://host:8096): add libraries → Movies: `/data/media/movies`,
Shows: `/data/media/tv`, Music: `/data/media/music`. Its media mount is
read-only; enable trickplay/metadata into `/config` (the default) — it never
writes to your media.
### 5. Optional: provider port forwarding
If your VPN provider gives you a forwarded port (AirVPN, ProtonVPN, …),
inbound peers can reach you and swarm connectivity improves:
1. Uncomment `FIREWALL_VPN_INPUT_PORTS` in the compose file and set the port.
2. For providers with a NAT-PMP API also uncomment `VPN_PORT_FORWARDING` and
`VPN_PORT_FORWARDING_PROVIDER`.
3. Set the same port as qBittorrent's listening port (Options → Connection)
and disable UPnP/NAT-PMP inside qBittorrent.
Never publish qBittorrent's BitTorrent port on the host — inbound connections
must arrive through the tunnel only.
---
## Local AI stack
### Why this design (and not plain Ollama)
| Piece | Choice | Reason |
|-------|--------|--------|
| Model | **Ternary-Bonsai-27B** (~7.2 GB Q2_0) | ~95% of FP16 quality at laptop footprint; strong coding/math |
| Runtime | **PrismML llama.cpp** (`llama-server`) | Custom Q2_0 g128 kernels — stock Ollama / mainline won't load the default GGUF |
| Hardware | **SER5 PRO** Ryzen 7 7735HS + Radeon 680M | No NVIDIA → default is **CPU**; optional Vulkan for the iGPU |
| Web UI | **Open WebUI** | Chat, document RAG, web search, tools, code interpreter |
| Search | **SearXNG** | Keyless private search for RAG |
| Coding | **OpenHands** (optional profile) + `/workspace` mount | Agent that edits files / runs commands in a sandbox |
**Memory ballpark** (model alone, from Prism docs): ~8 GB at 410k context, ~14 GB at 100k with FP16 KV. With Arr + Jellyfin running, **32 GB system RAM is the practical minimum**; **64 GB** is comfortable for long context + media.
### 1. Download the model (~7.2 GB)
```bash
# Recommended (resume-friendly):
pip install -U "huggingface_hub[cli]"
./scripts/download-bonsai-model.sh
# files land in ./models/bonsai/
```
Optional:
```bash
DOWNLOAD_DRAFTER=1 ./scripts/download-bonsai-model.sh # speculative decoding pack (CUDA-oriented)
```
### 2. Start AI services
```bash
# Build Prism llama-server image + start UI + search
docker compose up -d --build bonsai open-webui searxng
# Health
curl -s http://localhost:8081/health
curl -s http://localhost:8081/v1/models
```
Open **http://host:3000** — create the admin account on first visit.
Open WebUI is pre-pointed at `http://bonsai:8080/v1` (see compose env).
### 3. RAG (documents & local files)
**Upload / Knowledge collections (best for docs)**
1. Open WebUI → **Workspace → Knowledge** → create a collection.
2. Upload PDFs, markdown, code, notes.
3. In a chat, attach the collection (or enable it on the model).
Embeddings download into the Open WebUI volume on first use (local, no cloud).
**Whole folders via bind-mount**
`AI_WORKSPACE_DIR` (default `./workspace`) is mounted **read-only** at
`/workspace` inside Open WebUI. Put projects/notes there, or point
`.env` at your real code tree:
```bash
# .env
AI_WORKSPACE_DIR=/home/you/src
```
Then ask e.g. “Summarize the layout of `/workspace/my-app`” after adding those
paths to a Knowledge collection, or use tools/OpenHands for active editing.
**Web RAG**
SearXNG is wired as the search engine (`ENABLE_RAG_WEB_SEARCH=true`). In chat
settings enable web search when you want live results.
### 4. Coding helpers
| Tool | How | Best for |
|------|-----|----------|
| **Open WebUI + Knowledge** | Upload repo docs / mount `/workspace` | Q&A over code, design docs |
| **Open WebUI code interpreter** | Enabled in compose | Python plots, quick analysis |
| **OpenHands** | `docker compose --profile coding up -d openhands` | Multi-step “change the code / run tests” agents |
| **Continue / Aider / Cursor** (host apps) | Base URL `http://host:8081/v1`, any API key | IDE-native completion & edits |
**OpenHands** (http://host:3001):
1. Settings → enable Advanced.
2. Custom model: `openai/<id-from-/v1/models>` (or keep the pre-set env).
3. Base URL: `http://bonsai:8080/v1` (from inside Docker) or `http://host.docker.internal:8081/v1`.
4. API key: same as `OPENAI_API_KEY` in `.env`.
5. Workspace is `${AI_WORKSPACE_DIR}` — agent sandboxes use Docker socket.
> Note: Prism lists long-horizon multi-file agentic coding as a weaker area of
> this 27B release; it is still very usable for review, refactors, and smaller
> tasks. Prefer shorter goals and verify with tests.
### 5. Performance knobs (SER5 PRO)
```bash
# .env examples
# Leave headroom for Jellyfin/Arrs on a 16-thread 7735HS
BONSAI_THREADS=12
# Longer context for big repos / PDFs (needs RAM)
BONSAI_CTX=32768
BONSAI_KV4=1 # 4-bit KV cache if you approach OOM
# Optional: Vulkan offload on Radeon 680M
BONSAI_BACKEND=vulkan
BONSAI_NGL=99
# then uncomment devices/group_add under bonsai in docker-compose.yml
docker compose build --no-cache bonsai && docker compose up -d bonsai
```
Start with **CPU (`BONSAI_NGL=0`)**. iGPU Vulkan can help but shares system
RAM and is more fiddly in Docker; measure both with a short prompt if you care.
Recommended generation defaults (already set): `temp=0.7`, `top_p=0.95`,
`top_k=20`. The 27B is a **thinking** model — answers may include a reasoning
block; that is expected and improves quality.
### 6. Quick API smoke test
```bash
curl -s http://localhost:8081/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-local-bonsai" \
-d '{
"model": "local",
"messages": [{"role":"user","content":"Say hello in one short sentence."}],
"temperature": 0.7,
"max_tokens": 128
}'
```
(`model` can be any string or the id returned by `/v1/models` — llama-server is
usually lenient.)
### 7. What not to do
- Do **not** route bonsai/open-webui through gluetun — inference is local and
the VPN killswitch would only add pain.
- Do **not** expect stock Ollama to load `Ternary-Bonsai-27B-Q2_0.gguf` without
Prisms kernels (use our `bonsai` service).
- Keep Open WebUI / OpenHands off the public internet; use LAN + auth (`WEBUI_AUTH=true`).
---
## Layout after AI setup
```
arr-stack/
├── docker-compose.yml
├── .env / .env.example
├── ai/ # Prism llama-server image
│ ├── Dockerfile
│ └── entrypoint.sh
├── models/bonsai/ # GGUF weights (gitignored content)
├── workspace/ # files for RAG / OpenHands
├── searxng/settings.yml
├── scripts/download-bonsai-model.sh
├── config/ # app configs (open-webui, sonarr, …)
├── data/ # media + torrents
└── wireguard/wg0.conf
```
## Notes
- All app-to-app traffic uses container DNS names (`gluetun`, `sonarr`,
`bonsai`, …) on the internal `arr-net` bridge; only WebUIs are exposed to
your LAN.
- Update media stack with `docker compose pull && docker compose up -d`.
Gluetun is pinned to major version `v3` — read its release notes before
jumping majors.
- Rebuild AI runtime after Prism release bumps: edit `PRISM_RELEASE` in
`ai/Dockerfile`, then `docker compose build --no-cache bonsai`.
- Optional media extras (commented in compose): Jellyseerr.
- Optional coding profile: `docker compose --profile coding up -d openhands`.

67
ai/Dockerfile Normal file
View file

@ -0,0 +1,67 @@
# PrismML Ternary-Bonsai 27B server (llama-server OpenAI-compatible API)
# Uses prebuilt binaries from PrismML-Eng/llama.cpp — required for Q2_0 g128 GGUFs.
#
# Build args:
# BONSAI_BACKEND = cpu (default) | vulkan
# cpu — safe default for any host (SER5 PRO works well here)
# vulkan — AMD Radeon 680M offload; needs /dev/dri on the host + Mesa
ARG BONSAI_BACKEND=cpu
FROM ubuntu:24.04 AS base
ARG BONSAI_BACKEND
ENV DEBIAN_FRONTEND=noninteractive \
BONSAI_BACKEND=${BONSAI_BACKEND} \
PRISM_RELEASE=prism-b9596-9fcaed7
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl tar libgomp1 \
&& if [ "$BONSAI_BACKEND" = "vulkan" ]; then \
apt-get install -y --no-install-recommends \
libvulkan1 mesa-vulkan-drivers vulkan-tools; \
fi \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/bonsai
# Prebuilt Prism llama.cpp (group-128 Q2_0 kernels)
RUN set -eux; \
BASE="https://github.com/PrismML-Eng/llama.cpp/releases/download/${PRISM_RELEASE}"; \
case "$BONSAI_BACKEND" in \
vulkan) ASSET="llama-${PRISM_RELEASE}-bin-ubuntu-vulkan-x64.tar.gz"; DEST=bin/vulkan ;; \
cpu|*) ASSET="llama-${PRISM_RELEASE}-bin-ubuntu-x64.tar.gz"; DEST=bin/cpu ;; \
esac; \
mkdir -p "$DEST"; \
curl -fL --retry 3 "$BASE/$ASSET" -o /tmp/llama.tgz; \
tar -xzf /tmp/llama.tgz -C "$DEST" --strip-components=1 2>/dev/null \
|| tar -xzf /tmp/llama.tgz -C "$DEST"; \
rm -f /tmp/llama.tgz; \
test -x "$DEST/llama-server"; \
ln -sf "/opt/bonsai/$DEST/llama-server" /usr/local/bin/llama-server; \
printf '%s\n' "$PRISM_RELEASE" > "$DEST/.llama_release"
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Models mounted at runtime: /models
VOLUME ["/models"]
EXPOSE 8080
ENV LD_LIBRARY_PATH=/opt/bonsai/bin/cpu:/opt/bonsai/bin/vulkan \
MODEL_PATH=/models/Ternary-Bonsai-27B-Q2_0.gguf \
HOST=0.0.0.0 \
PORT=8080 \
NGL=0 \
CTX_SIZE=16384 \
TEMP=0.7 \
TOP_P=0.95 \
TOP_K=20 \
THREADS=0 \
KV4=0 \
EXTRA_ARGS=
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=5 \
CMD curl -fsS "http://127.0.0.1:${PORT}/health" || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

88
ai/entrypoint.sh Executable file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Start Prism llama-server with Ternary-Bonsai defaults.
set -euo pipefail
MODEL_PATH="${MODEL_PATH:-/models/Ternary-Bonsai-27B-Q2_0.gguf}"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8080}"
CTX_SIZE="${CTX_SIZE:-16384}"
TEMP="${TEMP:-0.7}"
TOP_P="${TOP_P:-0.95}"
TOP_K="${TOP_K:-20}"
NGL="${NGL:-0}"
THREADS="${THREADS:-0}"
KV4="${KV4:-0}"
EXTRA_ARGS="${EXTRA_ARGS:-}"
# Prefer vulkan binary tree if present and NGL > 0
BIN=/usr/local/bin/llama-server
if [ -x /opt/bonsai/bin/vulkan/llama-server ] && [ "${NGL}" != "0" ]; then
export LD_LIBRARY_PATH="/opt/bonsai/bin/vulkan${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
BIN=/opt/bonsai/bin/vulkan/llama-server
elif [ -x /opt/bonsai/bin/cpu/llama-server ]; then
export LD_LIBRARY_PATH="/opt/bonsai/bin/cpu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
BIN=/opt/bonsai/bin/cpu/llama-server
fi
if [ ! -f "$MODEL_PATH" ]; then
echo "ERROR: model not found at $MODEL_PATH"
echo "Run: ./scripts/download-bonsai-model.sh"
echo "Or set MODEL_PATH to your GGUF file."
exit 1
fi
# Optional vision projector (27B VLM)
MMPROJ_ARGS=()
if [ -n "${MMPROJ_PATH:-}" ] && [ -f "$MMPROJ_PATH" ]; then
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
else
# Auto-detect mmproj next to the model
_dir="$(dirname "$MODEL_PATH")"
for _mp in "$_dir"/*mmproj*.gguf; do
if [ -f "$_mp" ]; then
MMPROJ_ARGS=(--mmproj "$_mp")
break
fi
done
fi
# Optional 4-bit KV cache (long context on limited RAM)
KV_ARGS=()
if [ "$KV4" = "1" ]; then
KV_ARGS=(--cache-type-k q4_0 --cache-type-v q4_0)
fi
# Thread count: 0 = leave llama.cpp default (usually all physical cores)
THREAD_ARGS=()
if [ "$THREADS" != "0" ] && [ -n "$THREADS" ]; then
THREAD_ARGS=(-t "$THREADS")
fi
echo "=== Ternary-Bonsai llama-server ==="
echo " model: $MODEL_PATH"
echo " binary: $BIN"
echo " ngl: $NGL (0=CPU; for Vulkan image try 99 with /dev/dri)"
echo " context: $CTX_SIZE"
echo " api: http://${HOST}:${PORT}/v1/chat/completions"
[ "${#MMPROJ_ARGS[@]}" -gt 0 ] && echo " vision: ${MMPROJ_ARGS[1]}"
[ "$KV4" = "1" ] && echo " kv: q4_0"
echo ""
# shellcheck disable=SC2086
exec "$BIN" \
-m "$MODEL_PATH" \
--host "$HOST" \
--port "$PORT" \
-ngl "$NGL" \
-fa on \
-c "$CTX_SIZE" \
--temp "$TEMP" \
--top-p "$TOP_P" \
--top-k "$TOP_K" \
--min-p 0 \
--jinja \
"${THREAD_ARGS[@]}" \
"${MMPROJ_ARGS[@]}" \
"${KV_ARGS[@]}" \
$EXTRA_ARGS \
"$@"

132
ansible/README.md Normal file
View file

@ -0,0 +1,132 @@
# Homelab Ansible — Ubuntu 24.04 (SER5 PRO)
Bootstraps the headless mini PC for **arr-stack + Ternary-Bonsai (Vulkan)**.
| Item | Value |
|------|--------|
| Host | `192.168.8.123:22` |
| User | `tim` |
| SSH key | `~/.ssh/id_ed25519` |
| Stack | `/opt/stack` |
| Media | `/storage` |
## What it installs
1. **common** — apt upgrade, essentials (`curl` `wget` `nano` `build-essential` `net-tools`), [Microsoft Edit](https://github.com/microsoft/edit), tools, unattended-upgrades, fail2ban
2. **docker** — Docker CE + Compose plugin, enabled on boot, user in `docker`
3. **amd_vulkan** — Mesa Vulkan, VA-API, firmware, `video`/`render` groups, udev rules
4. **firewall** — UFW: deny inbound, allow stack ports **from LAN only** (`192.168.8.0/24`)
5. **stack**`/opt/stack` + `/storage`, rsync project, `.env`, Vulkan compose override, **`arr-stack.service`** so compose comes up after reboot (`restart: unless-stopped` on every service)
## Prerequisites (control machine)
```bash
# Debian/Ubuntu control node
sudo apt install -y ansible git rsync
cd /path/to/arr-stack/ansible
ansible-galaxy collection install -r requirements.yml
```
SSH must already work:
```bash
ssh -i ~/.ssh/id_ed25519 tim@192.168.8.123
```
On the **server**, either enable passwordless sudo (recommended for automation):
```bash
echo 'tim ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/tim
sudo chmod 440 /etc/sudoers.d/tim
```
…or pass the sudo password each run with `-K` / `--ask-become-pass`.
## Run
```bash
cd ansible
# Full bootstrap (prompt for sudo if not NOPASSWD)
ansible-playbook site.yml -K
# Or limit / tags
ansible-playbook site.yml -K --tags docker,amd,firewall
ansible-playbook site.yml -K --tags stack,deploy
ansible-playbook site.yml -K --check # dry-run (partial)
```
First connection may prompt for host key (`accept-new` is set in `ansible.cfg`).
## After the playbook
On the **server** (or via SSH):
```bash
ssh tim@192.168.8.123
# new shell so docker/video/render groups apply
cd /opt/stack
# WireGuard (if not copied): edit wireguard/wg0.conf
# Model (~7.2G):
./scripts/download-bonsai-model.sh
# Vulkan image + stack
docker compose build bonsai
docker compose up -d
docker compose ps
```
Verify GPU / tools inside the host:
```bash
vulkaninfo --summary
ls -l /dev/dri
groups # should include docker, video, render
edit --version # Microsoft terminal editor
curl --version && wget --version && nano --version
```
Survive reboots:
```bash
systemctl is-enabled docker arr-stack
systemctl status arr-stack
# containers use restart: unless-stopped; arr-stack.service runs compose up -d on boot
```
## Layout on the server
```
/opt/stack/ # compose, config, models, workspace, wireguard
docker-compose.yml
docker-compose.override.yml # /dev/dri for bonsai + jellyfin
.env
config/
models/bonsai/
workspace/
wireguard/wg0.conf
/storage/ # 1TB media disk
media/{movies,tv,music}
torrents/{movies,tv,music}
```
`.env` points `DATA_DIR=/storage` and `CONFIG_DIR=/opt/stack/config`.
## Variables
Edit `group_vars/all.yml` (or host_vars) for:
- `lan_subnet`, `timezone`
- `bonsai_backend` / `bonsai_ngl` (default `vulkan` / `99`)
- `ufw_allow_ssh_from_anywhere` (default `false` — SSH only from LAN)
- `deploy_stack_files` — set `false` to skip rsync
## Security notes
- Do not commit `wireguard/wg0.conf` or production `.env`.
- UFW allows service ports only from `lan_subnet`.
- Fail2ban protects SSH.
- Re-login after first run so group membership (`docker`, `render`) is active.

25
ansible/ansible.cfg Normal file
View file

@ -0,0 +1,25 @@
[defaults]
inventory = inventory/hosts.ini
roles_path = roles
remote_user = tim
private_key_file = ~/.ssh/id_ed25519
host_key_checking = True
retry_files_enabled = False
# community.general.yaml was removed; use the built-in default callback's YAML format
stdout_callback = ansible.builtin.default
# (ansible-core 2.13+; ignored on older cores that don't know this key)
callback_result_format = yaml
interpreter_python = auto_silent
deprecation_warnings = False
# Faster on a single host
forks = 5
timeout = 30
[privilege_escalation]
# become is set per-play in site.yml (not global) so ad-hoc ping works without sudo
become_method = sudo
become_user = root
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new

221
ansible/group_vars/all.yml Normal file
View file

@ -0,0 +1,221 @@
---
# ---------------------------------------------------------------------------
# Host layout
# ---------------------------------------------------------------------------
stack_root: /opt/stack
storage_root: /storage
stack_owner: tim
stack_group: tim
# Media tree lives on the 1TB disk
data_dir: "{{ storage_root }}"
config_dir: "{{ stack_root }}/config"
models_dir: "{{ stack_root }}/models/bonsai"
workspace_dir: "{{ stack_root }}/workspace"
wireguard_dir: "{{ stack_root }}/wireguard"
# LAN (SER5 is 192.168.8.123)
lan_subnet: 192.168.8.0/24
timezone: America/Los_Angeles
# ---------------------------------------------------------------------------
# Packages
# ---------------------------------------------------------------------------
# Always-on essentials (explicit — also listed in common_packages)
essential_packages:
- curl
- wget
- nano
- build-essential
- net-tools
# Microsoft Edit (https://github.com/microsoft/edit) — terminal editor
ms_edit_version: "2.0.0"
ms_edit_install: true
common_packages:
# essentials
- curl
- wget
- nano
- build-essential
- net-tools
# system / packaging
- ca-certificates
- gnupg
- apt-transport-https
- software-properties-common
- git
- python3
- python3-pip
- python3-venv
- cmake
- ninja-build
- pkg-config
# editors / viewers
- vim
- less
# monitoring / disk
- htop
- btop
- iotop
- iftop
- ncdu
- duf
- sysstat
- smartmontools
- lm-sensors
- nvme-cli
# network
- iproute2
- dnsutils
- traceroute
- mtr-tiny
- nmap
- tcpdump
# utils
- tree
- jq
- ripgrep
- fd-find
- fzf
- tmux
- rsync
- unzip
- zip
- p7zip-full
- lsof
- strace
- pciutils
- usbutils
- plocate
- bat
- aria2
# security / updates
- fail2ban
- unattended-upgrades
- update-notifier-common
# Start stack on boot (docker compose up -d); pairs with restart: unless-stopped
stack_systemd_enable: true
stack_compose_profiles: [] # e.g. ["coding"] to also start openhands
# AMD / Vulkan / media accel (Radeon 680M on SER5 PRO)
# Note: Ubuntu 24.04 uses mesa-va-drivers (not the older libva-mesa-driver name).
# Many of these live in the "universe" component — role enables it first.
amd_vulkan_packages:
- linux-firmware
- mesa-vulkan-drivers
- mesa-utils
- libvulkan1
- vulkan-tools
- libgl1-mesa-dri
- libegl1
- libgles2
- vainfo
- libva2
- libva-drm2
- mesa-va-drivers
- mesa-vdpau-drivers
- clinfo
# Groups for operator account (tim) — local trusted host, convenience over lockdown
extra_user_groups:
- sudo
- docker
- video
- render
- adm
- disk
- plugdev
- dialout
- cdrom
- users
- systemd-journal
# present on many Ubuntu images; harmless if already primary
- tim
# Passwordless full sudo for stack_owner (local monitored network)
stack_passwordless_sudo: true
# ---------------------------------------------------------------------------
# Docker
# ---------------------------------------------------------------------------
docker_packages:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
docker_daemon_json:
log-driver: json-file
log-opts:
max-size: "50m"
max-file: "5"
# Leave room for media + inference
default-address-pools:
- base: 172.30.0.0/16
size: 24
# Enable cgroup device access patterns used by /dev/dri
features:
buildkit: true
# ---------------------------------------------------------------------------
# Firewall (UFW) — default deny in, allow out; open stack ports to LAN only
# ---------------------------------------------------------------------------
ufw_enabled: true
ufw_default_incoming: deny
ufw_default_outgoing: allow
# SSH from anywhere on LAN (and optionally world — keep LAN-only by default)
ufw_allow_ssh_from_anywhere: false
ufw_lan_tcp_ports:
- { port: 22, comment: "SSH" }
- { port: 8080, comment: "qBittorrent" }
- { port: 8096, comment: "Jellyfin" }
- { port: 8989, comment: "Sonarr" }
- { port: 7878, comment: "Radarr" }
- { port: 9696, comment: "Prowlarr" }
- { port: 6767, comment: "Bazarr" }
- { port: 8686, comment: "Lidarr" }
- { port: 3000, comment: "Open WebUI" }
- { port: 8081, comment: "Bonsai llama-server" }
- { port: 3001, comment: "OpenHands (optional)" }
- { port: 3002, comment: "Forgejo HTTP" }
- { port: 2222, comment: "Forgejo Git SSH" }
# Forgejo (self-hosted Git)
forgejo_domain: "192.168.8.123"
forgejo_http_port: 3002
forgejo_ssh_port: 2222
forgejo_root_url: "http://{{ forgejo_domain }}:{{ forgejo_http_port }}/"
# ---------------------------------------------------------------------------
# Stack deployment
# ---------------------------------------------------------------------------
# Sync this repo (parent of ansible/) to stack_root on the server.
# Set false if you manage /opt/stack yourself (git clone, etc.).
deploy_stack_files: true
# Paths on the control machine (the machine running ansible-playbook)
# Default: the arr-stack repo that contains this ansible/ directory
stack_src: "{{ playbook_dir | dirname }}"
# AI defaults for SER5 + Vulkan
bonsai_backend: vulkan
bonsai_ngl: "99"
bonsai_ctx: "16384"
bonsai_threads: "12"
bonsai_kv4: "0"
open_webui_auth: "true"
# Media subdirs under storage_root
media_subdirs:
- media/movies
- media/tv
- media/music
- torrents/movies
- torrents/tv
- torrents/music

View file

@ -0,0 +1,7 @@
# Homelab mini PC (Beelink SER5 PRO / Ubuntu Server 24.04)
[homelab]
ser5 ansible_host=192.168.8.123 ansible_port=22 ansible_user=tim
[homelab:vars]
ansible_ssh_private_key_file=~/.ssh/id_ed25519
ansible_python_interpreter=/usr/bin/python3

6
ansible/requirements.yml Normal file
View file

@ -0,0 +1,6 @@
---
collections:
- name: community.general
version: ">=8.0.0"
- name: ansible.posix
version: ">=1.5.0"

View file

@ -0,0 +1,6 @@
---
- name: Reload udev
ansible.builtin.shell: |
udevadm control --reload-rules
udevadm trigger
changed_when: true

View file

@ -0,0 +1,102 @@
---
# Radeon 680M (RDNA2 iGPU) on SER5 PRO — Mesa Vulkan for llama.cpp / Jellyfin VAAPI
- name: Install software-properties-common (for add-apt-repository)
ansible.builtin.apt:
name: software-properties-common
state: present
tags: [amd, vulkan, gpu]
- name: Enable Ubuntu universe and multiverse components
ansible.builtin.command: add-apt-repository -y {{ item }}
loop:
- universe
- multiverse
register: apt_components
# With loop, changed_when runs per item — use item stdout, not .results
changed_when: "'Adding component' in (apt_components.stdout | default(''))"
failed_when: apt_components.rc is defined and apt_components.rc != 0
tags: [amd, vulkan, gpu]
- name: Update apt cache after enabling universe/multiverse
ansible.builtin.apt:
update_cache: true
tags: [amd, vulkan, gpu]
- name: Install AMD / Mesa / Vulkan packages
ansible.builtin.apt:
name: "{{ amd_vulkan_packages }}"
state: present
update_cache: false
tags: [amd, vulkan, gpu]
- name: Ensure video and render groups exist
ansible.builtin.group:
name: "{{ item }}"
state: present
loop:
- video
- render
tags: [amd, vulkan, gpu]
- name: Add stack user to video and render (GPU device access)
ansible.builtin.user:
name: "{{ stack_owner }}"
groups:
- video
- render
append: true
tags: [amd, vulkan, gpu]
- name: Check for DRM render nodes
ansible.builtin.find:
paths: /dev/dri
patterns: "renderD*,card*"
file_type: any
register: dri_nodes
tags: [amd, vulkan, gpu]
failed_when: false
- name: Report /dev/dri devices
ansible.builtin.debug:
msg: >-
DRM nodes:
{{ dri_nodes.files | map(attribute='path') | list | default(['(none found — check kernel / firmware)'], true) }}
tags: [amd, vulkan, gpu]
- name: Probe Vulkan (as stack user when possible)
become: true
become_user: "{{ stack_owner }}"
ansible.builtin.command: vulkaninfo --summary
register: vulkan_summary
changed_when: false
failed_when: false
tags: [amd, vulkan, gpu]
- name: Show Vulkan summary (first lines)
ansible.builtin.debug:
msg: "{{ (vulkan_summary.stdout_lines | default(['vulkaninfo failed or not usable yet']))[:40] }}"
tags: [amd, vulkan, gpu]
- name: Probe VA-API
ansible.builtin.command: vainfo
register: vainfo_out
changed_when: false
failed_when: false
tags: [amd, vulkan, gpu]
- name: Show vainfo snippet
ansible.builtin.debug:
msg: "{{ (vainfo_out.stdout_lines | default(vainfo_out.stderr_lines | default(['vainfo unavailable'])))[:25] }}"
tags: [amd, vulkan, gpu]
- name: udev rule — ensure render nodes are accessible by render group
ansible.builtin.copy:
dest: /etc/udev/rules.d/99-amd-render.rules
mode: "0644"
content: |
# Homelab: AMD GPU / APU render nodes for Docker + Vulkan
KERNEL=="renderD*", GROUP="render", MODE="0660"
KERNEL=="card*", GROUP="video", MODE="0660"
notify: Reload udev
tags: [amd, vulkan, gpu]

View file

@ -0,0 +1,5 @@
---
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted

View file

@ -0,0 +1,179 @@
---
- name: Set system timezone
community.general.timezone:
name: "{{ timezone }}"
tags: [common, timezone]
- name: Wait for any existing unattended-upgrade / dpkg lock
ansible.builtin.shell: |
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do
sleep 5
done
changed_when: false
tags: [common, apt]
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
tags: [common, apt]
- name: Upgrade all packages
ansible.builtin.apt:
upgrade: dist
autoremove: true
autoclean: true
tags: [common, apt, upgrade]
- name: Install essential packages (curl wget nano build-essential net-tools)
ansible.builtin.apt:
name: "{{ essential_packages }}"
state: present
tags: [common, packages, essentials]
- name: Install common packages
ansible.builtin.apt:
name: "{{ common_packages }}"
state: present
tags: [common, packages]
# ---------------------------------------------------------------------------
# Microsoft Edit — terminal-based editor (https://github.com/microsoft/edit)
# ---------------------------------------------------------------------------
- name: Resolve Microsoft Edit archive name for this arch
ansible.builtin.set_fact:
ms_edit_arch_triple: >-
{{
'x86_64-linux-gnu' if ansible_architecture in ['x86_64', 'amd64']
else (
'aarch64-linux-gnu' if ansible_architecture in ['aarch64', 'arm64']
else 'UNSUPPORTED'
)
}}
when: ms_edit_install | bool
tags: [common, packages, edit]
- name: Fail if Microsoft Edit has no binary for this architecture
ansible.builtin.fail:
msg: "No Microsoft Edit release for architecture {{ ansible_architecture }}"
when:
- ms_edit_install | bool
- ms_edit_arch_triple == 'UNSUPPORTED'
tags: [common, packages, edit]
- name: Check whether Microsoft Edit is already installed at the desired version
ansible.builtin.command: /usr/local/bin/edit --version
register: ms_edit_installed
changed_when: false
failed_when: false
when: ms_edit_install | bool
tags: [common, packages, edit]
- name: Download Microsoft Edit release tarball
ansible.builtin.get_url:
url: >-
https://github.com/microsoft/edit/releases/download/v{{ ms_edit_version }}/edit-{{ ms_edit_version }}-{{ ms_edit_arch_triple | trim }}.tar.gz
dest: "/tmp/edit-{{ ms_edit_version }}.tar.gz"
mode: "0644"
when:
- ms_edit_install | bool
- ms_edit_installed.rc is not defined or ms_edit_installed.rc != 0
or (ms_edit_version not in (ms_edit_installed.stdout | default('')))
tags: [common, packages, edit]
- name: Install Microsoft Edit binary to /usr/local/bin/edit
ansible.builtin.unarchive:
src: "/tmp/edit-{{ ms_edit_version }}.tar.gz"
dest: /usr/local/bin
remote_src: true
mode: "0755"
owner: root
group: root
when:
- ms_edit_install | bool
- ms_edit_installed.rc is not defined or ms_edit_installed.rc != 0
or (ms_edit_version not in (ms_edit_installed.stdout | default('')))
tags: [common, packages, edit]
- name: Verify Microsoft Edit is on PATH
ansible.builtin.command: /usr/local/bin/edit --version
register: ms_edit_verify
changed_when: false
failed_when: ms_edit_verify.rc != 0
when: ms_edit_install | bool
tags: [common, packages, edit]
- name: Show Microsoft Edit version
ansible.builtin.debug:
msg: "{{ ms_edit_verify.stdout | default(ms_edit_installed.stdout) | default('edit installed') }}"
when: ms_edit_install | bool
tags: [common, packages, edit]
- name: Enable unattended-upgrades
ansible.builtin.debconf:
name: unattended-upgrades
question: unattended-upgrades/enable_auto_updates
value: "true"
vtype: boolean
tags: [common, unattended]
- name: Configure unattended-upgrades (security only)
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
tags: [common, unattended]
- name: Prefer security updates in unattended-upgrades
ansible.builtin.lineinfile:
path: /etc/apt/apt.conf.d/50unattended-upgrades
regexp: '{{ item.regexp }}'
line: '{{ item.line }}'
state: present
loop:
- regexp: '^\s*//\s*"\$\{distro_id\}:\$\{distro_codename\}-security";'
line: ' "${distro_id}:${distro_codename}-security";'
- regexp: 'Unattended-Upgrade::Remove-Unused-Dependencies'
line: 'Unattended-Upgrade::Remove-Unused-Dependencies "true";'
failed_when: false
tags: [common, unattended]
- name: Enable and start fail2ban
ansible.builtin.service:
name: fail2ban
state: started
enabled: true
tags: [common, fail2ban]
- name: Fail2ban SSH jail (sshd)
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/sshd.local
mode: "0644"
content: |
[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 5
bantime = 1h
findtime = 10m
notify: Restart fail2ban
tags: [common, fail2ban]
- name: Ensure plocate database is built
ansible.builtin.command: updatedb
changed_when: false
failed_when: false
tags: [common]
- name: Detect sensors (lm-sensors)
ansible.builtin.command: sensors-detect --auto
args:
creates: /etc/sensors3.conf
failed_when: false
changed_when: false
tags: [common]

View file

@ -0,0 +1,5 @@
---
- name: Restart docker
ansible.builtin.service:
name: docker
state: restarted

View file

@ -0,0 +1,91 @@
---
- name: Install Docker apt prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
state: present
tags: [docker]
- name: Create keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
tags: [docker]
- name: Add Docker official GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
force: true
tags: [docker]
- name: Add Docker apt repository (deb822)
ansible.builtin.deb822_repository:
name: docker
types: [deb]
uris: https://download.docker.com/linux/ubuntu
suites: ["{{ ansible_distribution_release }}"]
components: [stable]
architectures: "{{ [ansible_architecture | replace('x86_64', 'amd64') | replace('aarch64', 'arm64')] }}"
signed_by: /etc/apt/keyrings/docker.asc
state: present
enabled: true
tags: [docker]
- name: Install Docker Engine + Compose plugin
ansible.builtin.apt:
name: "{{ docker_packages }}"
state: present
update_cache: true
tags: [docker]
- name: Ensure docker group exists
ansible.builtin.group:
name: docker
state: present
tags: [docker]
- name: Configure Docker daemon.json
ansible.builtin.copy:
dest: /etc/docker/daemon.json
mode: "0644"
content: "{{ docker_daemon_json | to_nice_json }}\n"
notify: Restart docker
tags: [docker]
- name: Enable and start Docker (survives reboots)
ansible.builtin.systemd:
name: docker
state: started
enabled: true
tags: [docker]
- name: Enable containerd (Docker dependency)
ansible.builtin.systemd:
name: containerd
state: started
enabled: true
tags: [docker]
- name: Add stack user to docker / video / render groups
ansible.builtin.user:
name: "{{ stack_owner }}"
groups: "{{ extra_user_groups }}"
append: true
tags: [docker, users]
# Full permission setup also runs in the users role
- name: Verify docker works
ansible.builtin.command: docker version --format '{% raw %}{{.Server.Version}}{% endraw %}'
register: docker_ver
changed_when: false
tags: [docker]
- name: Show docker version
ansible.builtin.debug:
msg: "Docker server {{ docker_ver.stdout }}"
tags: [docker]

View file

@ -0,0 +1,71 @@
---
- name: Install UFW
ansible.builtin.apt:
name: ufw
state: present
tags: [firewall, ufw]
- name: Set UFW default policies
community.general.ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: "{{ ufw_default_incoming }}" }
- { direction: outgoing, policy: "{{ ufw_default_outgoing }}" }
tags: [firewall, ufw]
- name: Allow OpenSSH (rate-limited) from LAN
community.general.ufw:
rule: limit
port: "22"
proto: tcp
src: "{{ lan_subnet }}"
comment: "SSH from LAN"
tags: [firewall, ufw]
- name: Allow OpenSSH from anywhere (optional)
community.general.ufw:
rule: limit
port: "22"
proto: tcp
comment: "SSH world"
when: ufw_allow_ssh_from_anywhere | bool
tags: [firewall, ufw]
- name: Allow stack service ports from LAN
community.general.ufw:
rule: allow
port: "{{ item.port | string }}"
proto: tcp
src: "{{ lan_subnet }}"
comment: "{{ item.comment }}"
loop: "{{ ufw_lan_tcp_ports }}"
# SSH already handled above with limit; skip duplicate 22 if listed
when: item.port | int != 22
tags: [firewall, ufw]
- name: Allow Docker published ports on loopback freely (implicit)
ansible.builtin.debug:
msg: "UFW will leave local/docker internal traffic alone; LAN rules cover published WebUIs."
tags: [firewall, ufw]
# Docker + UFW: without this, published ports can bypass UFW on some setups.
# Route forwarded packets through ufw-before-forward is complex; we rely on
# binding services to LAN + ufw allow from lan_subnet. Documented in README.
- name: Enable UFW
community.general.ufw:
state: enabled
when: ufw_enabled | bool
tags: [firewall, ufw]
- name: Show UFW status
ansible.builtin.command: ufw status verbose
register: ufw_status
changed_when: false
tags: [firewall, ufw]
- name: Print UFW status
ansible.builtin.debug:
msg: "{{ ufw_status.stdout_lines }}"
tags: [firewall, ufw]

View file

@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true

View file

@ -0,0 +1,207 @@
---
# Layout: compose + config under /opt/stack, media under /storage
- name: Ensure stack owner exists
ansible.builtin.user:
name: "{{ stack_owner }}"
state: present
shell: /bin/bash
create_home: true
tags: [stack]
- name: Create /opt/stack hierarchy
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0755"
loop:
- "{{ stack_root }}"
- "{{ config_dir }}"
- "{{ models_dir }}"
- "{{ workspace_dir }}"
- "{{ wireguard_dir }}"
- "{{ stack_root }}/ai"
- "{{ stack_root }}/scripts"
- "{{ stack_root }}/searxng"
- "{{ stack_root }}/ansible"
tags: [stack]
- name: Ensure storage root exists
ansible.builtin.file:
path: "{{ storage_root }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0755"
tags: [stack, storage]
- name: Create media / torrent directories on /storage
ansible.builtin.file:
path: "{{ storage_root }}/{{ item }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0755"
loop: "{{ media_subdirs }}"
tags: [stack, storage]
- name: Deploy stack files from control machine
ansible.posix.synchronize:
src: "{{ stack_src }}/"
dest: "{{ stack_root }}/"
delete: false
recursive: true
rsync_opts:
- "--exclude=.git"
- "--exclude=config/"
- "--exclude=data/"
- "--exclude=models/bonsai/*.gguf"
- "--exclude=*.partial"
- "--exclude=.env"
- "--exclude=wireguard/wg0.conf"
- "--exclude=ansible/.vault_pass"
when: deploy_stack_files | bool
become: false
tags: [stack, deploy]
- name: Fix ownership after rsync
ansible.builtin.file:
path: "{{ stack_root }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
recurse: true
when: deploy_stack_files | bool
tags: [stack, deploy]
- name: Deploy production .env for this host
ansible.builtin.template:
src: env.j2
dest: "{{ stack_root }}/.env"
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0600"
tags: [stack, env]
- name: Deploy WireGuard config if present on control machine
ansible.builtin.copy:
src: "{{ stack_src }}/wireguard/wg0.conf"
dest: "{{ wireguard_dir }}/wg0.conf"
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0600"
when:
- deploy_stack_files | bool
ignore_errors: true
tags: [stack, wireguard]
- name: Note missing local wg0.conf
ansible.builtin.debug:
msg: >-
Place wireguard/wg0.conf on the control machine (or copy to
{{ wireguard_dir }}/wg0.conf on the server) before starting gluetun.
tags: [stack, wireguard]
- name: Resolve host video group GID (for Docker group_add)
ansible.builtin.command: getent group video
register: getent_video
changed_when: false
failed_when: false
tags: [stack, compose, vulkan]
- name: Resolve host render group GID (for Docker group_add)
ansible.builtin.command: getent group render
register: getent_render
changed_when: false
failed_when: false
tags: [stack, compose, vulkan]
- name: Set video/render GID facts
ansible.builtin.set_fact:
video_gid: "{{ (getent_video.stdout.split(':')[2]) if getent_video.rc == 0 else '' }}"
render_gid: "{{ (getent_render.stdout.split(':')[2]) if getent_render.rc == 0 else '' }}"
tags: [stack, compose, vulkan]
- name: Write docker compose override for Vulkan / paths
ansible.builtin.template:
src: docker-compose.override.yml.j2
dest: "{{ stack_root }}/docker-compose.override.yml"
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0644"
tags: [stack, compose, vulkan]
- name: Loginctl linger for stack user (optional user services later)
ansible.builtin.command: loginctl enable-linger {{ stack_owner }}
changed_when: false
failed_when: false
tags: [stack]
# ---------------------------------------------------------------------------
# Persist stack across reboots
# - Every service in docker-compose.yml uses restart: unless-stopped
# - Docker itself is enabled on boot (docker role)
# - This unit runs `docker compose up -d` after docker is ready
# ---------------------------------------------------------------------------
- name: "Assert all active compose services use restart: unless-stopped"
ansible.builtin.shell: |
set -euo pipefail
# Extract service-level restart policies (ignore comments)
bad=$(grep -E '^\s+restart:' "{{ stack_root }}/docker-compose.yml" \
| grep -v 'unless-stopped' \
| grep -v '^\s*#' || true)
if [ -n "$bad" ]; then
echo "Services missing restart: unless-stopped:"
echo "$bad"
exit 1
fi
count=$(grep -cE '^\s+restart:\s*unless-stopped' "{{ stack_root }}/docker-compose.yml" || true)
echo "OK: $count service(s) with restart: unless-stopped"
args:
executable: /bin/bash
register: restart_policy_check
changed_when: false
tags: [stack, restart]
- name: Show restart policy check
ansible.builtin.debug:
msg: "{{ restart_policy_check.stdout }}"
tags: [stack, restart]
- name: Install systemd unit for arr-stack compose
ansible.builtin.template:
src: arr-stack.service.j2
dest: /etc/systemd/system/arr-stack.service
mode: "0644"
when: stack_systemd_enable | bool
notify: Reload systemd
tags: [stack, restart, systemd]
- name: Enable arr-stack systemd unit (start on boot)
ansible.builtin.systemd:
name: arr-stack.service
enabled: true
daemon_reload: true
when: stack_systemd_enable | bool
tags: [stack, restart, systemd]
- name: Start arr-stack now (docker compose up -d)
ansible.builtin.systemd:
name: arr-stack.service
state: started
when: stack_systemd_enable | bool
# May fail on first run if images/model not ready — do not hard-fail bootstrap
failed_when: false
register: arr_stack_start
tags: [stack, restart, systemd]
- name: Report arr-stack service start result
ansible.builtin.debug:
msg: >-
arr-stack.service:
{{ 'started/active' if (arr_stack_start is succeeded and not arr_stack_start.failed | default(false))
else 'enabled for boot; start deferred (build images / download model first, then: sudo systemctl start arr-stack)' }}
when: stack_systemd_enable | bool
tags: [stack, restart, systemd]

View file

@ -0,0 +1,23 @@
[Unit]
Description=arr-stack Docker Compose services
Documentation=file://{{ stack_root }}/README.md
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
User={{ stack_owner }}
Group={{ stack_group }}
WorkingDirectory={{ stack_root }}
Environment=HOME=/home/{{ stack_owner }}
# All compose services use restart: unless-stopped; this unit brings the stack
# up after boot / docker restarts.
ExecStart=/usr/bin/docker compose {% if stack_compose_profiles | length > 0 %}--profile {{ stack_compose_profiles | join(' --profile ') }} {% endif %}up -d --remove-orphans
ExecStop=/usr/bin/docker compose stop
TimeoutStartSec=0
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,29 @@
# Generated by Ansible — merges with docker-compose.yml
# group_add uses numeric host GIDs (names fail inside Ubuntu base images).
services:
{% if bonsai_backend == "vulkan" %}
bonsai:
devices:
- /dev/dri:/dev/dri
{% if video_gid is defined and video_gid | length > 0 %}
group_add:
- "{{ video_gid }}"
{% if render_gid is defined and render_gid | length > 0 %}
- "{{ render_gid }}"
{% endif %}
{% endif %}
environment:
NGL: "{{ bonsai_ngl }}"
{% endif %}
jellyfin:
devices:
- /dev/dri:/dev/dri
{% if video_gid is defined and video_gid | length > 0 %}
group_add:
- "{{ video_gid }}"
{% if render_gid is defined and render_gid | length > 0 %}
- "{{ render_gid }}"
{% endif %}
{% endif %}

View file

@ -0,0 +1,41 @@
# Generated by Ansible for {{ inventory_hostname }} — do not commit
# Host: {{ ansible_host | default(inventory_hostname) }}
PUID={{ ansible_user_uid | default(1000) }}
PGID={{ ansible_user_gid | default(1000) }}
TZ={{ timezone }}
CONFIG_DIR={{ config_dir }}
DATA_DIR={{ data_dir }}
LAN_SUBNET={{ lan_subnet }}
# --- AI (Ternary-Bonsai + Vulkan on SER5) ---
BONSAI_MODELS_DIR={{ models_dir }}
BONSAI_MODEL_PATH=/models/Ternary-Bonsai-27B-Q2_0.gguf
BONSAI_BACKEND={{ bonsai_backend }}
BONSAI_NGL={{ bonsai_ngl }}
BONSAI_CTX={{ bonsai_ctx }}
BONSAI_KV4={{ bonsai_kv4 }}
BONSAI_THREADS={{ bonsai_threads }}
BONSAI_TEMP=0.7
BONSAI_TOP_P=0.95
BONSAI_TOP_K=20
BONSAI_PORT=8081
OPEN_WEBUI_PORT=3000
OPENAI_API_KEY=sk-local-bonsai
WEBUI_AUTH={{ open_webui_auth }}
AI_WORKSPACE_DIR={{ workspace_dir }}
SEARXNG_PORT=8888
SEARXNG_SECRET={{ lookup('password', playbook_dir + '/.secrets/searxng_secret chars=ascii_letters,digits length=48') }}
OPENHANDS_PORT=3001
OPENHANDS_MODEL=openai/local
# Forgejo (self-hosted Git)
FORGEJO_HTTP_PORT={{ forgejo_http_port }}
FORGEJO_SSH_PORT={{ forgejo_ssh_port }}
FORGEJO_DOMAIN={{ forgejo_domain }}
FORGEJO_ROOT_URL={{ forgejo_root_url }}
FORGEJO_DISABLE_REGISTRATION=false

View file

@ -0,0 +1,6 @@
---
- name: Reload udev
ansible.builtin.shell: |
udevadm control --reload-rules
udevadm trigger
changed_when: true

View file

@ -0,0 +1,171 @@
---
# Local homelab operator account — broad permissions (LAN-trusted host)
- name: Ensure stack user exists with bash shell
ansible.builtin.user:
name: "{{ stack_owner }}"
state: present
shell: /bin/bash
create_home: true
groups: "{{ extra_user_groups }}"
append: true
tags: [users, permissions]
- name: Ensure useful system groups exist (create if missing)
ansible.builtin.group:
name: "{{ item }}"
state: present
loop: "{{ extra_user_groups }}"
failed_when: false
tags: [users, permissions]
- name: Add stack user to all useful groups
ansible.builtin.user:
name: "{{ stack_owner }}"
groups: "{{ extra_user_groups }}"
append: true
tags: [users, permissions]
- name: Passwordless sudo for stack user (full admin)
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ stack_owner }}"
mode: "0440"
owner: root
group: root
validate: "visudo -cf %s"
content: |
# Homelab operator — passwordless sudo (local trusted host)
{{ stack_owner }} ALL=(ALL) NOPASSWD:ALL
Defaults:{{ stack_owner }} !env_reset
Defaults:{{ stack_owner }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
tags: [users, permissions, sudo]
- name: Allow stack user to manage Docker and arr-stack via systemctl without password
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ stack_owner }}-services"
mode: "0440"
owner: root
group: root
validate: "visudo -cf %s"
content: |
# Redundant with NOPASSWD:ALL; kept explicit for clarity if sudo is tightened later
{{ stack_owner }} ALL=(root) NOPASSWD: /bin/systemctl * docker*, /bin/systemctl * containerd*, /bin/systemctl * arr-stack*, /usr/bin/systemctl * docker*, /usr/bin/systemctl * containerd*, /usr/bin/systemctl * arr-stack*
tags: [users, permissions, sudo]
- name: Install ACL tools
ansible.builtin.apt:
name: acl
state: present
tags: [users, permissions]
- name: Own stack and storage roots
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0775"
loop:
- "{{ stack_root }}"
- "{{ storage_root }}"
tags: [users, permissions]
- name: Recursive ownership of stack tree
ansible.builtin.file:
path: "{{ stack_root }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
recurse: true
tags: [users, permissions]
- name: Recursive ownership of storage tree
ansible.builtin.file:
path: "{{ storage_root }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
recurse: true
# Large trees: may take a moment; safe to re-run
tags: [users, permissions]
- name: Default ACLs so new files under stack stay group-writable
ansible.builtin.command: >
setfacl -R -d -m u:{{ stack_owner }}:rwx,g:{{ stack_group }}:rwx,o::r-x {{ item }}
loop:
- "{{ stack_root }}"
- "{{ storage_root }}"
changed_when: true
failed_when: false
tags: [users, permissions]
- name: Current ACLs for stack owner full access
ansible.builtin.command: >
setfacl -R -m u:{{ stack_owner }}:rwx,g:{{ stack_group }}:rwx {{ item }}
loop:
- "{{ stack_root }}"
- "{{ storage_root }}"
changed_when: true
failed_when: false
tags: [users, permissions]
- name: Discover DRM nodes for permission tweak
ansible.builtin.find:
paths: /dev/dri
patterns: "*"
file_type: any
register: dri_nodes
failed_when: false
tags: [users, permissions, gpu]
- name: Make /dev/dri nodes world-accessible (trusted local host)
ansible.builtin.shell: |
set -e
if [ -d /dev/dri ]; then
chmod a+rw /dev/dri/* 2>/dev/null || true
ls -la /dev/dri
fi
register: dri_chmod
changed_when: "'rw' in dri_chmod.stdout"
failed_when: false
tags: [users, permissions, gpu]
- name: Persist permissive DRM udev rule for trusted host
ansible.builtin.copy:
dest: /etc/udev/rules.d/99-homelab-dri-permissive.rules
mode: "0644"
content: |
# Trusted LAN homelab — allow any user/container access to GPU nodes
KERNEL=="renderD*", MODE="0666"
KERNEL=="card*", MODE="0666"
KERNEL=="controlD*", MODE="0666"
notify: Reload udev
tags: [users, permissions, gpu]
- name: Ensure docker socket is group-writable for docker group
ansible.builtin.file:
path: /var/run/docker.sock
group: docker
mode: "0660"
failed_when: false
tags: [users, permissions, docker]
- name: Verify stack user can sudo without password
become: true
become_user: "{{ stack_owner }}"
ansible.builtin.command: sudo -n true
changed_when: false
tags: [users, permissions]
- name: Show stack user identity and groups
become: true
become_user: "{{ stack_owner }}"
ansible.builtin.command: id
register: stack_user_id
changed_when: false
tags: [users, permissions]
- name: Report stack user permissions
ansible.builtin.debug:
msg: "{{ stack_user_id.stdout }}"
tags: [users, permissions]

69
ansible/site.yml Normal file
View file

@ -0,0 +1,69 @@
---
# Bootstrap Ubuntu Server 24.04 headless host for arr-stack + local AI (Vulkan)
#
# Usage (from this directory):
# ansible-galaxy collection install -r requirements.yml
# ansible-playbook site.yml -l ser5
#
# Tags: common, docker, amd, vulkan, firewall, stack, upgrade, deploy
- name: Prepare homelab host
hosts: homelab
gather_facts: true
become: true
pre_tasks:
- name: Verify target is Ubuntu
ansible.builtin.assert:
that:
- ansible_distribution == "Ubuntu"
- ansible_distribution_major_version is version('24', '>=')
fail_msg: "This playbook targets Ubuntu 24.04+. Found {{ ansible_distribution }} {{ ansible_distribution_version }}"
tags: [always]
- name: Resolve stack user uid/gid
ansible.builtin.getent:
database: passwd
key: "{{ stack_owner }}"
tags: [always]
- name: Set PUID/PGID facts from passwd
ansible.builtin.set_fact:
ansible_user_uid: "{{ getent_passwd[stack_owner][1] }}"
ansible_user_gid: "{{ getent_passwd[stack_owner][2] }}"
tags: [always]
roles:
- role: common
tags: [common]
- role: docker
tags: [docker]
- role: amd_vulkan
tags: [amd, vulkan, gpu]
- role: firewall
tags: [firewall]
- role: stack
tags: [stack]
# After stack paths exist — ownership, sudo, groups, GPU nodes
- role: users
tags: [users, permissions]
post_tasks:
- name: Summary
ansible.builtin.debug:
msg: |
Host ready: {{ inventory_hostname }} ({{ ansible_host }})
Stack root: {{ stack_root }} (owned by {{ stack_owner }})
Media root: {{ storage_root }} (owned by {{ stack_owner }})
User: {{ stack_owner }} — passwordless sudo, docker/video/render/…
Docker: enabled on boot
Vulkan: Mesa packages + permissive /dev/dri udev (trusted host)
Firewall: UFW LAN {{ lan_subnet }}
Next on the server (re-login once for groups):
cd {{ stack_root }}
docker compose build bonsai && docker compose up -d
Open WebUI: http://{{ ansible_host }}:3000
Jellyfin: http://{{ ansible_host }}:8096
tags: [always]

View file

@ -0,0 +1,22 @@
# GPU access for Vulkan (bonsai) + VA-API (jellyfin).
#
# Do NOT use group names like "video"/"render" here — Docker resolves them
# inside the container image, which often has no "render" group and fails with:
# unable to find group render: no matching entries in group file
#
# Options:
# A) Rely on host /dev/dri being 0666 (Ansible users role udev rule) — no group_add.
# B) Use numeric host GIDs: getent group video render
#
# Ansible regenerates this file with host GIDs when you run the stack role.
services:
bonsai:
devices:
- /dev/dri:/dev/dri
environment:
NGL: "99"
jellyfin:
devices:
- /dev/dri:/dev/dri

372
docker-compose.yml Normal file
View file

@ -0,0 +1,372 @@
name: arr-stack
################################################################################
# Arr stack
# gluetun WireGuard VPN client + firewall killswitch
# qbittorrent torrent client, lives INSIDE gluetun's network namespace
# prowlarr indexer manager
# sonarr TV radarr movies bazarr subtitles
# jellyfin media server
#
# AI / coding stack (local, no VPN)
# bonsai Prism Ternary-Bonsai-27B via llama-server (OpenAI-compatible API)
# open-webui Chat UI + RAG + tools + code interpreter
# searxng Private metasearch for web RAG
# forgejo Self-hosted Git (repos for coding + OpenHands)
# openhands Optional autonomous coding agent (profile: coding)
#
# Lockdown model:
# * qbittorrent uses `network_mode: service:gluetun` -> it has NO network
# interface of its own. Every packet it sends/receives goes through
# gluetun, and gluetun's built-in firewall only lets traffic out via the
# WireGuard tunnel. Tunnel down = zero connectivity. No leaks possible.
# * Do NOT publish qBittorrent's BitTorrent listening port on the host -
# inbound peer connections must come through the tunnel (port forwarding),
# never via your real IP.
# * AI services stay on arr-net (LAN only). They do NOT route through gluetun.
################################################################################
networks:
arr-net:
driver: bridge
services:
# ------------------------------------------------------------- VPN gateway #
gluetun:
image: qmcgaw/gluetun:v3
container_name: gluetun
restart: unless-stopped
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
networks:
- arr-net
ports:
# Ports for anything running inside gluetun's namespace are published
# HERE, on gluetun - not on the inner container.
- 8080:8080/tcp # qBittorrent WebUI
volumes:
# Your provider's WireGuard config, renamed to wg0.conf
# (see wireguard/wg0.conf.example and the README for required edits)
- ./wireguard/wg0.conf:/gluetun/wireguard/wg0.conf:ro
environment:
VPN_SERVICE_PROVIDER: custom
VPN_TYPE: wireguard
TZ: ${TZ}
# Lets your LAN reach the qBittorrent WebUI through gluetun's firewall
FIREWALL_OUTBOUND_SUBNETS: ${LAN_SUBNET}
# --- optional: provider port forwarding (better swarm connectivity) ---
# FIREWALL_VPN_INPUT_PORTS: "12345" # port your provider assigned you
# VPN_PORT_FORWARDING: "on" # for providers with NAT-PMP API
# VPN_PORT_FORWARDING_PROVIDER: protonvpn
# --------------------------------------------------------- torrent client #
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
restart: unless-stopped
network_mode: service:gluetun # <-- the entire isolation trick
depends_on:
gluetun:
condition: service_healthy # start only once the tunnel is up
environment:
PUID: ${PUID}
PGID: ${PGID}
TZ: ${TZ}
WEBUI_PORT: "8080"
volumes:
- ${CONFIG_DIR}/qbittorrent:/config
- ${DATA_DIR}:/data # single shared tree -> hardlinks
# -------------------------------------------------------------- indexers #
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
restart: unless-stopped
networks: [arr-net]
ports:
- 9696:9696
environment: &arr-env
PUID: ${PUID}
PGID: ${PGID}
TZ: ${TZ}
volumes:
- ${CONFIG_DIR}/prowlarr:/config
# -------------------------------------------------------------------- TV #
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
networks: [arr-net]
ports:
- 8989:8989
environment: *arr-env
volumes:
- ${CONFIG_DIR}/sonarr:/config
- ${DATA_DIR}:/data
# ----------------------------------------------------------------- movies #
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
networks: [arr-net]
ports:
- 7878:7878
environment: *arr-env
volumes:
- ${CONFIG_DIR}/radarr:/config
- ${DATA_DIR}:/data
# -------------------------------------------------------------- subtitles #
bazarr:
image: lscr.io/linuxserver/bazarr:latest
container_name: bazarr
restart: unless-stopped
networks: [arr-net]
ports:
- 6767:6767
environment: *arr-env
volumes:
- ${CONFIG_DIR}/bazarr:/config
- ${DATA_DIR}:/data
# ----------------------------------------------------------- media server #
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
networks: [arr-net]
ports:
- 8096:8096/tcp # web UI / clients
# - 8920:8920/tcp # optional HTTPS
# - 7359:7359/udp # optional client autodiscovery
environment: *arr-env
volumes:
- ${CONFIG_DIR}/jellyfin:/config
- ${DATA_DIR}/media:/data/media:ro # read-only: Jellyfin never touches files
# --- optional: Intel QuickSync hardware transcoding --- (Unused on this AMD machine)
# devices:
# - /dev/dri:/dev/dri
# ------------------------------------------------------------------- music #
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
restart: unless-stopped
networks: [arr-net]
ports:
- 8686:8686
environment: *arr-env
volumes:
- ${CONFIG_DIR}/lidarr:/config
- ${DATA_DIR}:/data # same /data tree as Sonarr/Radarr
# ==========================================================================
# Optional media extras - uncomment as needed
# ==========================================================================
#
# jellyseerr: # request UI for Jellyfin
# image: fallenbagel/jellyseerr:latest
# container_name: jellyseerr
# restart: unless-stopped
# networks: [arr-net]
# ports: ["5055:5055"]
# environment: *arr-env
# volumes:
# - ${CONFIG_DIR}/jellyseerr:/app/config
# ==========================================================================
# Local AI — Ternary-Bonsai-27B + Open WebUI + RAG + coding helpers
# Target: Beelink SER5 PRO (Ryzen 7 7735HS + Radeon 680M, no NVIDIA)
# ==========================================================================
# OpenAI-compatible API for Ternary-Bonsai-27B (PrismML Q2_0 GGUF)
# First: ./scripts/download-bonsai-model.sh
# Then: docker compose up -d --build bonsai open-webui searxng
bonsai:
build:
context: ./ai
dockerfile: Dockerfile
args:
# cpu = reliable default on SER5 PRO
# vulkan = try Radeon 680M offload (also set NGL=99 and uncomment devices)
BONSAI_BACKEND: ${BONSAI_BACKEND:-vulkan}
image: arr-stack/bonsai:local
container_name: bonsai
restart: unless-stopped
networks: [arr-net]
ports:
- "${BONSAI_PORT:-8081}:8080" # avoid clash with qBittorrent :8080
environment:
TZ: ${TZ}
MODEL_PATH: ${BONSAI_MODEL_PATH:-/models/Ternary-Bonsai-27B-Q2_0.gguf}
# 0 = CPU (recommended start). Vulkan image: try 99 with /dev/dri mounted.
NGL: ${BONSAI_NGL:-99}
# 16k is a good default on 3264 GB systems with media stack co-resident.
# Long docs / full-repo: try 32768 or 65536; enable KV4=1 if RAM is tight.
CTX_SIZE: ${BONSAI_CTX:-16384}
TEMP: ${BONSAI_TEMP:-0.7}
TOP_P: ${BONSAI_TOP_P:-0.95}
TOP_K: ${BONSAI_TOP_K:-20}
# 0 = auto; or set to physical cores (e.g. 8 on 7735HS) leaving some for Arrs
THREADS: ${BONSAI_THREADS:-0}
KV4: ${BONSAI_KV4:-0}
EXTRA_ARGS: ${BONSAI_EXTRA_ARGS:-}
volumes:
- ${BONSAI_MODELS_DIR:-./models/bonsai}:/models:ro
# Shared memory helps large context / mmap
shm_size: "4gb"
# Vulkan / AMD iGPU: devices + group_add live ONLY in
# docker-compose.override.yml (Compose merges lists and rejects duplicates).
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 10s
start_period: 180s
retries: 5
# ChatGPT-style UI with built-in RAG, tools, and code interpreter
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
restart: unless-stopped
networks: [arr-net]
ports:
- "${OPEN_WEBUI_PORT:-3000}:8080"
depends_on:
bonsai:
condition: service_healthy
searxng:
condition: service_started
environment:
TZ: ${TZ}
# Point at local llama-server (OpenAI-compatible)
OPENAI_API_BASE_URL: http://bonsai:8080/v1
OPENAI_API_KEY: ${OPENAI_API_KEY:-sk-local-bonsai}
# Disable Ollama unless you add it later
ENABLE_OLLAMA_API: "false"
# First account becomes admin; set WEBUI_AUTH=false only on trusted LAN
WEBUI_AUTH: ${WEBUI_AUTH:-true}
# RAG: local embeddings run inside open-webui (downloads on first use)
RAG_EMBEDDING_ENGINE: ${RAG_EMBEDDING_ENGINE:-}
CHUNK_SIZE: ${RAG_CHUNK_SIZE:-1000}
CHUNK_OVERLAP: ${RAG_CHUNK_OVERLAP:-100}
# Web search via local SearXNG
ENABLE_RAG_WEB_SEARCH: "true"
ENABLE_SEARCH_QUERY: "true"
RAG_WEB_SEARCH_ENGINE: searxng
SEARXNG_QUERY_URL: http://searxng:8080/search?q=<query>
# Allow fetching internal / LAN URLs for RAG (homelab docs)
ENABLE_RAG_LOCAL_WEB_FETCH: "true"
# Code interpreter (runs in open-webui sandbox)
ENABLE_CODE_INTERPRETER: "true"
# Reduce background LLM spam on a single local model
ENABLE_TITLE_GENERATION: "false"
ENABLE_FOLLOW_UP_GENERATION: "false"
ENABLE_TAGS_GENERATION: "false"
# Default model id as reported by llama-server /v1/models
DEFAULT_MODELS: ${OPENWEBUI_DEFAULT_MODEL:-}
volumes:
- ${CONFIG_DIR}/open-webui:/app/backend/data
# Your local files for RAG / "read my project" workflows
- ${AI_WORKSPACE_DIR:-./workspace}:/workspace:ro
# Optional: expose media library read-only for Q&A about your library tree
# - ${DATA_DIR}/media:/media:ro
extra_hosts:
- "host.docker.internal:host-gateway"
# Private metasearch (no API keys) for Open WebUI web RAG
searxng:
image: docker.io/searxng/searxng:latest
container_name: searxng
restart: unless-stopped
networks: [arr-net]
# Bind to localhost only — Open WebUI reaches it on arr-net
ports:
- "127.0.0.1:${SEARXNG_PORT:-8888}:8080"
environment:
SEARXNG_BASE_URL: http://localhost:${SEARXNG_PORT:-8888}/
SEARXNG_SECRET: ${SEARXNG_SECRET:-change-me-to-a-long-random-string}
volumes:
- ./searxng:/etc/searxng:rw
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
# -------------------------------------------------------------------------
# Forgejo — self-hosted Git (Gitea soft-fork). SQLite is fine for a homelab.
# Web: http://host:3002 Git SSH: host:2222 (git@host:user/repo.git with port 2222)
# First visit: create admin account, then create repos / push code.
# -------------------------------------------------------------------------
forgejo:
image: codeberg.org/forgejo/forgejo:11
container_name: forgejo
restart: unless-stopped
networks: [arr-net]
ports:
- "${FORGEJO_HTTP_PORT:-3002}:3000"
- "${FORGEJO_SSH_PORT:-2222}:22"
environment:
USER_UID: ${PUID:-1000}
USER_GID: ${PGID:-1000}
TZ: ${TZ}
# Image already runs OpenSSH on :22 (mapped to host FORGEJO_SSH_PORT).
# Do NOT enable Forgejo's built-in SSH too — both bind :22 and the
# process fatals with "address already in use" (connection refused on :3002).
FORGEJO__server__START_SSH_SERVER: "false"
FORGEJO__server__DISABLE_SSH: "false"
FORGEJO__server__SSH_PORT: ${FORGEJO_SSH_PORT:-2222}
FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN:-192.168.8.123}
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN:-192.168.8.123}
FORGEJO__server__ROOT_URL: ${FORGEJO_ROOT_URL:-http://192.168.8.123:3002/}
FORGEJO__server__HTTP_PORT: "3000"
# SQLite by default (no extra DB container)
FORGEJO__database__DB_TYPE: sqlite3
FORGEJO__service__DISABLE_REGISTRATION: ${FORGEJO_DISABLE_REGISTRATION:-false}
FORGEJO__actions__ENABLED: "true"
volumes:
- ${CONFIG_DIR}/forgejo:/data
- /etc/localtime:/etc/localtime:ro
# -------------------------------------------------------------------------
# OpenHands — autonomous coding agent (opt-in)
# Start with: docker compose --profile coding up -d openhands
# UI: http://host:3001 → Settings → custom model openai/<id>
# Base URL: http://bonsai:8080/v1 API key: sk-local-bonsai
# Needs Docker socket to spawn sandboxed runtimes.
# -------------------------------------------------------------------------
openhands:
profiles: ["coding"]
# Prefer GHCR — docker.all-hands.dev often fails DNS / is decommissioned
image: ghcr.io/all-hands-ai/openhands:0.54
container_name: openhands
restart: unless-stopped
networks: [arr-net]
ports:
- "${OPENHANDS_PORT:-3001}:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
TZ: ${TZ}
SANDBOX_RUNTIME_CONTAINER_IMAGE: ghcr.io/all-hands-ai/runtime:0.54-nikolaik
LOG_ALL_EVENTS: "true"
# Pre-wire local Bonsai (override in UI if model id differs)
LLM_BASE_URL: http://bonsai:8080/v1
LLM_API_KEY: ${OPENAI_API_KEY:-sk-local-bonsai}
LLM_MODEL: ${OPENHANDS_MODEL:-openai/local}
WORKSPACE_MOUNT_PATH: ${AI_WORKSPACE_DIR:-./workspace}
SANDBOX_VOLUMES: ${AI_WORKSPACE_DIR:-./workspace}:/workspace:rw
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CONFIG_DIR}/openhands:/.openhands-state
- ${AI_WORKSPACE_DIR:-./workspace}:/opt/workspace_base
depends_on:
bonsai:
condition: service_healthy

1
models/bonsai/.gitkeep Normal file
View file

@ -0,0 +1 @@
# Place Ternary-Bonsai-27B GGUF files here (see scripts/download-bonsai-model.sh)

138
quickstart.md Normal file
View file

@ -0,0 +1,138 @@
# Quickstart
Homelab host: **`192.168.8.123`** (SER5, Ubuntu 24.04)
Stack: **`/opt/stack`** · Media: **`/storage`** · User: **`tim`**
---
## Ansible
From a machine with this repo and SSH key `~/.ssh/id_ed25519`:
```bash
cd ansible
ansible-galaxy collection install -r requirements.yml
ansible-playbook site.yml -K # -K only if sudo still asks once; playbook installs NOPASSWD for tim
```
What it does: OS updates, tools (incl. Microsoft `edit`), Docker, AMD/Vulkan, UFW (LAN only), `/opt/stack` + `/storage`, `.env`, `arr-stack.service`, and **tim** as local admin (passwordless sudo, docker/video/render/…, owns stack + storage, permissive GPU nodes).
**Before first full stack start** (on ser5):
```bash
ssh tim@192.168.8.123
# new login so docker/video/render groups apply
cd /opt/stack
# wireguard/wg0.conf must exist (no IPv6; Endpoint = IP not hostname)
./scripts/download-bonsai-model.sh # ~7.2 GB GGUF
docker compose build bonsai
docker compose up -d
# or: sudo systemctl enable --now arr-stack
docker compose ps
```
**Compose GPU notes**
- Keep `devices`/`group_add` only in `docker-compose.override.yml` (not also in the main file).
- Use **numeric GIDs** for `group_add`, not names (`video`/`render` fail inside the image). Or omit `group_add` if `/dev/dri` is mode `0666`.
Re-run playbook anytime: `ansible-playbook site.yml -K`
Tags: `--tags common,docker,amd,firewall,stack`
---
## Remotes
LAN URLs (UFW allows **`192.168.8.0/24`**). Replace host if needed.
| Service | URL |
|---------|-----|
| Jellyfin | http://192.168.8.123:8096 |
| qBittorrent | http://192.168.8.123:8080 |
| Prowlarr | http://192.168.8.123:9696 |
| Sonarr | http://192.168.8.123:8989 |
| Radarr | http://192.168.8.123:7878 |
| Bazarr | http://192.168.8.123:6767 |
| Lidarr | http://192.168.8.123:8686 |
| Open WebUI | http://192.168.8.123:3000 |
| Bonsai API | http://192.168.8.123:8081/v1 |
| OpenHands | http://192.168.8.123:3001 |
| **Forgejo** | http://192.168.8.123:3002 |
| Forgejo Git SSH | `ssh://git@192.168.8.123:2222/user/repo.git` |
OpenHands only runs with: `docker compose --profile coding up -d` (or `stack_compose_profiles: [coding]` in Ansible).
Images are from **GHCR** (`ghcr.io/all-hands-ai/...`); the old `docker.all-hands.dev` host often does not resolve.
### Manual config (once)
**qBittorrent** — temp password in `docker logs qbittorrent`; user `admin`.
Advanced → Network interface: `tun0`.
Default save path + categories (WebUI):
1. **Tools → Options → Downloads**
- Default Save Path: `/data/torrents`
- Keep “Keep incomplete torrents in” off (or a subfolder under `/data/torrents` if you prefer).
2. **Categories** (same Options → Downloads page, or rightclick empty area in the left “Categories” panel → **Add category**):
| Category name | Save path |
|---------------|-----------|
| `movies` | `/data/torrents/movies` |
| `tv` | `/data/torrents/tv` |
| `music` | `/data/torrents/music` |
For each: **Add category** → name exactly `movies` / `tv` / `music` (lowercase, matches Sonarr/Radarr/Lidarr) → set **Save path** to the path above → OK.
3. Sonarr/Radarr/Lidarr download-client “Category” fields must match those names so torrents land in the right folder for hardlinks.
**Sonarr / Radarr / Lidarr** — Download client qBittorrent: host **`gluetun`**, port **`8080`**, categories `tv` / `movies` / `music`.
Root folders: `/data/media/tv`, `/data/media/movies`, `/data/media/music` (hardlinks on).
**Prowlarr** — Add indexers. Apps: Sonarr `http://sonarr:8989`, Radarr `http://radarr:7878`, Lidarr `http://lidarr:8686` + each API key (*Settings → General*).
**Bazarr** — Sonarr/Radarr URLs + API keys; paths `/data/media/tv`, `/data/media/movies`.
**Jellyfin** — Libraries: Movies `/data/media/movies`, Shows `/data/media/tv`, Music `/data/media/music`.
**Open WebUI** — Create admin on first visit. Model connection is pre-set to Bonsai (`http://bonsai:8080/v1`). Enable web search in chat if desired. RAG: Workspace → Knowledge (uploads) or files under `/workspace`.
**Bonsai** — No UI login. Smoke test: `curl -s http://192.168.8.123:8081/v1/models`. Needs GGUF in `models/bonsai/` and `docker compose build bonsai` once.
**OpenHands** — On the server (`/opt/stack`): `docker compose --profile coding up -d openhands`.
Settings → Advanced: base URL `http://bonsai:8080/v1`, API key `sk-local-bonsai` (or `.env` value), model `openai/<id from /v1/models>`.
**Forgejo** — First visit http://192.168.8.123:3002 → install wizard (SQLite is pre-selected via env) → create **admin**.
If connection refused: container crash-looped (built-in SSH + image OpenSSH both on :22). Compose sets `START_SSH_SERVER=false`; recreate with `docker compose up -d forgejo --force-recreate`.
Then: **+ → New Repository** → clone into workspace:
```bash
# HTTPS
git clone http://192.168.8.123:3002/YOU/repo.git /opt/stack/workspace/repo
# SSH (port 2222 — host :22 stays for machine login)
git clone ssh://git@192.168.8.123:2222/YOU/repo.git /opt/stack/workspace/repo
# or: git remote add origin ssh://git@192.168.8.123:2222/YOU/repo.git
```
Point OpenHands / Open WebUI `AI_WORKSPACE_DIR` at those clones. After admin exists, set `FORGEJO_DISABLE_REGISTRATION=true` in `.env` and recreate: `docker compose up -d forgejo`.
---
## Locals
Bound to loopback on the host (not LAN-reachable as published):
| Service | URL / path |
|---------|------------|
| SearXNG | http://127.0.0.1:8888 |
Open WebUI talks to SearXNG on the Docker network (`http://searxng:8080`); you only need the loopback URL for direct browser checks on the server.
### Manual config
**SearXNG** — None for normal use. Settings: `/opt/stack/searxng/settings.yml`. Secret comes from `.env` (`SEARXNG_SECRET`).
**WireGuard** — File only: `/opt/stack/wireguard/wg0.conf` (not a web UI). Must be ready before gluetun is healthy.
**Host shell** — `ssh tim@192.168.8.123` · stack logs: `docker compose -f /opt/stack/docker-compose.yml logs -f` · unit: `systemctl status arr-stack`.

112
scripts/download-bonsai-model.sh Executable file
View file

@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Download Ternary-Bonsai-27B GGUF (and optional vision / drafter packs)
# into ./models/bonsai for the docker-compose bonsai service.
#
# Requires: ~810 GB free disk for the base model.
# Prefer: pip install -U "huggingface_hub[cli]" # provides `hf`
# Optional: HF_TOKEN / BONSAI_TOKEN if the repo ever requires auth.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="${MODEL_DIR:-$ROOT/models/bonsai}"
REPO="${BONSAI_HF_REPO:-prism-ml/Ternary-Bonsai-27B-gguf}"
# Group-128 Q2_0 — needs PrismML llama.cpp (what our Docker image ships)
MAIN_FILE="${BONSAI_MAIN_FILE:-Ternary-Bonsai-27B-Q2_0.gguf}"
DOWNLOAD_VISION="${DOWNLOAD_VISION:-1}"
DOWNLOAD_DRAFTER="${DOWNLOAD_DRAFTER:-0}"
mkdir -p "$OUT"
cd "$OUT"
export HF_TOKEN="${HF_TOKEN:-${BONSAI_TOKEN:-}}"
have_hf() {
command -v hf >/dev/null 2>&1 || command -v huggingface-cli >/dev/null 2>&1
}
hf_get() {
local file="$1"
if command -v hf >/dev/null 2>&1; then
hf download "$REPO" "$file" --local-dir .
else
huggingface-cli download "$REPO" "$file" --local-dir .
fi
}
curl_get() {
local file="$1"
local url="https://huggingface.co/${REPO}/resolve/main/${file}"
local auth=()
if [ -n "${HF_TOKEN:-}" ]; then
auth=(-H "Authorization: Bearer ${HF_TOKEN}")
fi
curl -fL --retry 5 --continue-at - "${auth[@]}" "$url" -o "${file}.partial"
mv "${file}.partial" "$file"
}
download() {
local file="$1"
if [ -f "$file" ] && [ -s "$file" ]; then
echo "Already present: $file ($(du -h "$file" | cut -f1))"
return 0
fi
echo "Downloading $file ..."
if have_hf; then
hf_get "$file"
else
echo " (tip: install huggingface_hub for more reliable downloads: pip install -U 'huggingface_hub[cli]')"
curl_get "$file"
fi
echo " OK: $file ($(du -h "$file" | cut -f1))"
}
echo "Target directory: $OUT"
echo "Repo: $REPO"
echo ""
download "$MAIN_FILE"
if [ "$DOWNLOAD_VISION" = "1" ]; then
echo ""
echo "Optional vision projector (mmproj) — attempting known filenames..."
_got=0
for f in \
Ternary-Bonsai-27B-mmproj-HQQ4.gguf \
Ternary-Bonsai-27B-mmproj-f16.gguf \
mmproj-Ternary-Bonsai-27B.gguf \
; do
if [ -f "$f" ]; then
echo "Already present: $f"
_got=1
break
fi
if have_hf; then
if hf_get "$f" 2>/dev/null; then
_got=1
break
fi
fi
done
if [ "$_got" != "1" ]; then
echo "Vision mmproj not found automatically (optional for text-only)."
echo " List files: https://huggingface.co/${REPO}/tree/main"
echo " Place any *mmproj*.gguf into $OUT and restart bonsai."
fi
fi
if [ "$DOWNLOAD_DRAFTER" = "1" ]; then
echo ""
echo "Optional DSpark drafter (speculative decoding; mainly helps CUDA)..."
for f in Ternary-Bonsai-27B-dspark-Q4_1.gguf; do
download "$f" || echo " skipped: $f"
done
fi
echo ""
echo "Done. GGUF files in $OUT:"
ls -lh "$OUT"/*.gguf 2>/dev/null || ls -lh "$OUT"
echo ""
echo "Next:"
echo " docker compose up -d --build bonsai open-webui searxng"
echo " Open WebUI → http://localhost:3000"
echo " Raw API → http://localhost:8081/v1/models"

29
searxng/settings.yml Normal file
View file

@ -0,0 +1,29 @@
# Minimal SearXNG config for Open WebUI web-search RAG.
# See https://docs.searxng.org/admin/settings/settings.html
use_default_settings: true
general:
instance_name: "arr-stack searxng"
enable_metrics: false
search:
safe_search: 0
autocomplete: ""
default_lang: "en"
formats:
- html
- json
server:
# Shared secret for limiter / sessions; override via SEARXNG_SECRET in compose if you like
secret_key: "change-me-in-production-use-env"
limiter: false
image_proxy: true
ui:
static_use_hash: true
# Disable outbound image proxy noise; keep JSON API for Open WebUI
outgoing:
request_timeout: 10.0

View file

@ -0,0 +1,27 @@
# Rename YOUR provider's config file to `wg0.conf` and put it in this folder.
# It looks roughly like this:
#
# [Interface]
# PrivateKey = yAnz..................................=
# Address = 10.2.0.2/32
# DNS = 10.2.0.1
#
# [Peer]
# PublicKey = xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=
# PresharedKey = ......................................= # only if present
# AllowedIPs = 0.0.0.0/0
# Endpoint = 185.159.157.12:51820
#
# Two required edits before gluetun accepts it:
#
# 1. Remove any IPv6 addresses from the Address and DNS lines, e.g.
# Address = 10.182.160.190/32, fd7d:76ee:...::/128
# becomes
# Address = 10.182.160.190/32
#
# 2. The Endpoint must be an IP address, not a hostname. If yours says
# something like `Endpoint = se3.vpn.example.org:51820`, resolve it first:
# dig +short se3.vpn.example.org
# and paste the IP back into the file.
#
# Keep AllowedIPs = 0.0.0.0/0 as-is; gluetun handles the routing itself.

2
workspace/README.md Normal file
View file

@ -0,0 +1,2 @@
# AI workspace — mount point for Open WebUI (/workspace) and OpenHands
# Drop project folders, notes, and docs here for local file Q&A and coding tasks.