Initial commit: arr-stack homelab + local AI

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

132
ansible/README.md Normal file
View file

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

25
ansible/ansible.cfg Normal file
View file

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

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

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

View file

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

6
ansible/requirements.yml Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

69
ansible/site.yml Normal file
View file

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