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:
commit
3645d1314c
33 changed files with 2673 additions and 0 deletions
74
.env.example
Normal file
74
.env.example
Normal 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 32–64 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
18
.gitignore
vendored
Normal 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
323
README.md
Normal 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 4–10k 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
|
||||||
|
Prism’s 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
67
ai/Dockerfile
Normal 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
88
ai/entrypoint.sh
Executable 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
132
ansible/README.md
Normal 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
25
ansible/ansible.cfg
Normal 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
221
ansible/group_vars/all.yml
Normal 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
|
||||||
7
ansible/inventory/hosts.ini
Normal file
7
ansible/inventory/hosts.ini
Normal 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
6
ansible/requirements.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
collections:
|
||||||
|
- name: community.general
|
||||||
|
version: ">=8.0.0"
|
||||||
|
- name: ansible.posix
|
||||||
|
version: ">=1.5.0"
|
||||||
6
ansible/roles/amd_vulkan/handlers/main.yml
Normal file
6
ansible/roles/amd_vulkan/handlers/main.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
- name: Reload udev
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
udevadm control --reload-rules
|
||||||
|
udevadm trigger
|
||||||
|
changed_when: true
|
||||||
102
ansible/roles/amd_vulkan/tasks/main.yml
Normal file
102
ansible/roles/amd_vulkan/tasks/main.yml
Normal 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]
|
||||||
5
ansible/roles/common/handlers/main.yml
Normal file
5
ansible/roles/common/handlers/main.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
- name: Restart fail2ban
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: fail2ban
|
||||||
|
state: restarted
|
||||||
179
ansible/roles/common/tasks/main.yml
Normal file
179
ansible/roles/common/tasks/main.yml
Normal 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]
|
||||||
5
ansible/roles/docker/handlers/main.yml
Normal file
5
ansible/roles/docker/handlers/main.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
- name: Restart docker
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: docker
|
||||||
|
state: restarted
|
||||||
91
ansible/roles/docker/tasks/main.yml
Normal file
91
ansible/roles/docker/tasks/main.yml
Normal 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]
|
||||||
71
ansible/roles/firewall/tasks/main.yml
Normal file
71
ansible/roles/firewall/tasks/main.yml
Normal 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]
|
||||||
4
ansible/roles/stack/handlers/main.yml
Normal file
4
ansible/roles/stack/handlers/main.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
- name: Reload systemd
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
207
ansible/roles/stack/tasks/main.yml
Normal file
207
ansible/roles/stack/tasks/main.yml
Normal 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]
|
||||||
23
ansible/roles/stack/templates/arr-stack.service.j2
Normal file
23
ansible/roles/stack/templates/arr-stack.service.j2
Normal 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
|
||||||
29
ansible/roles/stack/templates/docker-compose.override.yml.j2
Normal file
29
ansible/roles/stack/templates/docker-compose.override.yml.j2
Normal 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 %}
|
||||||
41
ansible/roles/stack/templates/env.j2
Normal file
41
ansible/roles/stack/templates/env.j2
Normal 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
|
||||||
6
ansible/roles/users/handlers/main.yml
Normal file
6
ansible/roles/users/handlers/main.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
- name: Reload udev
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
udevadm control --reload-rules
|
||||||
|
udevadm trigger
|
||||||
|
changed_when: true
|
||||||
171
ansible/roles/users/tasks/main.yml
Normal file
171
ansible/roles/users/tasks/main.yml
Normal 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
69
ansible/site.yml
Normal 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]
|
||||||
22
docker-compose.override.yml
Normal file
22
docker-compose.override.yml
Normal 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
372
docker-compose.yml
Normal 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 32–64 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
1
models/bonsai/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Place Ternary-Bonsai-27B GGUF files here (see scripts/download-bonsai-model.sh)
|
||||||
138
quickstart.md
Normal file
138
quickstart.md
Normal 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 right‑click 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
112
scripts/download-bonsai-model.sh
Executable 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: ~8–10 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
29
searxng/settings.yml
Normal 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
|
||||||
27
wireguard/wg0.conf.example
Normal file
27
wireguard/wg0.conf.example
Normal 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
2
workspace/README.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue