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