Added Jellyfin deployment to the repo
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
Russell Seymour 2026-08-31 20:14:43 +01:00
parent d59f463d3d
commit 3cd33d350e
Signed by: russells
GPG key ID: 78116D73A1E3EC41
15 changed files with 653 additions and 0 deletions

View file

@ -17,3 +17,36 @@ stacks:
# with no DATABASE_URL support) and Prowlarr keeps its own SQLite under # with no DATABASE_URL support) and Prowlarr keeps its own SQLite under
# /config, so there is nothing to provision on the shared Postgres. Both # /config, so there is nothing to provision on the shared Postgres. Both
# databases live in the appdata bind mounts along with everything else. # databases live in the appdata bind mounts along with everything else.
# Trialling alongside an existing Plex install to compare the two — see
# src/jellyfin/ansible/unraid/vars.yml, which points its media paths at the
# same library Plex already reads. No `db:` block: Jellyfin is SQLite-only,
# same reasoning as arr above.
- name: jellyfin
src: jellyfin
vault_path: homelab/jellyfin
appdata:
- path: /mnt/user/Media/JellyBridge
owner: "99"
group: "100"
mode: "0777"
recurse: false
# Syncs watch state between the Plex and Jellyfin instances above — the
# reason both are running side by side. SQLite-only, no `db:` block, same
# as jellyfin. Its `homelab/watchstate` Vault path holds nothing real yet
# (first-run wizard + per-backend tokens in /config), but compose_stack
# looks it up unconditionally so it still has to exist — see
# docs/vault-secrets.md.
- name: watchstate
src: watchstate
vault_path: homelab/watchstate
# WatchState runs rootless (container `user: 99:100`) and exits if it
# can't write /config. Docker would create the missing bind path as
# root:root on first `up`, so pre-create it owned by nobody:users. Keep
# this path in step with WATCHSTATE_CONFIG_PATH in
# src/watchstate/ansible/unraid/vars.yml.
appdata:
- path: /mnt/user/appdata/watchstate/config
owner: "99"
group: "100"

View file

@ -23,6 +23,31 @@
state: directory state: directory
mode: "0750" mode: "0750"
# Bind-mount source directories the stack needs to exist *with a specific
# owner* before `docker compose up`. Opt-in per stack (`appdata:` on the
# host_vars entry) and normally unset: the LinuxServer images (arr, jellyfin)
# start as root and chown their own /config on boot, and postgres/forgejo run
# as root outright, so for those Docker creating a missing bind path as
# root:root is fine. A rootless image (watchstate runs as uid 99 and refuses
# to start if it can't write /config) has nothing to do the chown, so the
# directory has to arrive already owned correctly — otherwise the first
# `up` creates it as root:root and the container exits.
#
# `recurse` defaults on, matching upstream's `chown -R`, to also fix a
# directory that a previous run already created wrong. Keep the list to small
# state/config dirs; don't point it at a media share.
- name: Ensure stack appdata directories exist with the right ownership
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner | default(omit) }}"
group: "{{ item.group | default(omit) }}"
mode: "{{ item.mode | default('0750') }}"
recurse: "{{ item.recurse | default(true) }}"
loop: "{{ stack.appdata | default([]) }}"
loop_control:
label: "{{ item.path }}"
- name: Copy docker-compose.yml - name: Copy docker-compose.yml
ansible.builtin.copy: ansible.builtin.copy:
src: "{{ stack_local_dir }}/ansible/unraid/docker-compose.yml" src: "{{ stack_local_dir }}/ansible/unraid/docker-compose.yml"

View file

@ -230,6 +230,24 @@ an unrecognised variable is ignored rather than fatal, so the symptom of the
wrong form is a key in the UI that doesn't match Vault, not a container that wrong form is a key in the UI that doesn't match Vault, not a container that
won't start. won't start.
## `homelab/watchstate`
No real secrets. WatchState creates its admin through a first-run web wizard,
and each backend (Plex, Jellyfin) is added afterwards from the WebUI or
`docker exec -ti watchstate console`, with its API token kept in `/config` —
nothing is injected as an env var. But `compose_stack` fetches `vault_path`
unconditionally, so the path still has to exist or the deploy fails at the
lookup:
```sh
vault kv put kv/homelab/watchstate placeholder=unused
```
Same situation as `homelab/jellyfin`. Revisit if a future WatchState release
grows a pre-settable API key or an OIDC client secret — add the key here and
name it in `src/watchstate/ansible/unraid/docker-compose.yml`'s `environment:`
(a Vault key that nothing references never reaches the container).
## `homelab/k3s-homelab-utils` ## `homelab/k3s-homelab-utils`
| Key | Purpose | | Key | Purpose |

View file

@ -0,0 +1,21 @@
# ansible/proxmox/ — jellyfin
Not built. Jellyfin runs as a Compose stack on nas1 (`../unraid/`) — this
folder exists because every app keeps the same shape whether or not both
platforms are in use.
It's also a poor fit for a move today, for reasons specific to what this
deployment is for: it's evaluated against Plex, which reads Unraid's array
directly, and it needs read access to the same media shares (see
`../unraid/vars.yml`). Reaching those from a Proxmox LXC means an NFS/SMB
mount into the guest instead of a native Unraid share path — solvable, but
not free, and there is no reason to solve it while this is still a
side-by-side trial. Hardware transcoding (see the note in
`../unraid/vars.yml`) is a second complication if it's ever enabled: a GPU
passed through to nas1 for Jellyfin isn't available to a Proxmox guest at
the same time.
What wouldn't change: `../../common/vars.yml` already holds the portable
config (version, published port), and the Vault path stays
`homelab/jellyfin`. Only paths and PUID/PGID are Unraid-specific, and those
live in `../unraid/vars.yml`.

View file

@ -0,0 +1,49 @@
# Reference only — real values are rendered by Ansible from vars.yml (non-
# secret) and Vault path homelab/jellyfin (see ../../common/vars.yml — there
# are no real secrets yet, but the path still has to exist). Do not fill this
# in and deploy it directly.
# Non-secret (see ../../common/vars.yml)
JELLYFIN_VERSION=latest
JELLYFIN_HTTP_PORT=8096
# Non-secret (see vars.yml) — check the media paths against your own shares.
# Point these at the same library Plex already reads.
JELLYFIN_CONFIG_PATH=/mnt/user/appdata/jellyfin/config
JELLYFIN_TRANSCODE_PATH=/mnt/user/appdata/jellyfin/transcode
JELLYFIN_MOVIES_PATH=/mnt/user/Media/Movies
JELLYFIN_TV_PATH=/mnt/user/Media/TV
PUID=99
PGID=100
TZ=Europe/London
# Non-secret (see vars.yml) — NVENC hardware transcoding. `all`/`all` is
# upstream's own recommended value, not a placeholder. Requires ich777's
# Nvidia-Driver plugin on the host; without it these reach the container but
# there's no GPU device inside it, and Jellyfin falls back to the CPU.
NVIDIA_DRIVER_CAPABILITIES=all
NVIDIA_VISIBLE_DEVICES=all
# Docker network to join, and it must already exist on the target host —
# Compose declares it `external`, so it will not create it. Defined in
# Unraid's Docker settings, not by any stack in this repo.
JELLYFIN_NETWORK=caddy-net
# Non-secret (see vars.yml) — LAN DNS server the container forwards to, so
# split-horizon names the SSO plugin needs (auth.turtlesystems.uk) resolve
# from inside the container. caddy-net's embedded resolver otherwise hands
# these to nas1's public DNS, which has no record for them.
JELLYFIN_DNS=192.168.50.251
# Non-secret (see vars.yml) — SSO provider hostname pinned to an IPv4-only
# /etc/hosts entry (extra_hosts in docker-compose.yml), so Jellyfin's SSO
# plugin doesn't trip over a public AAAA record that has no split-horizon
# override. Drop once internal DNS is authoritative (A-only) for this name.
JELLYFIN_SSO_HOST=auth.turtlesystems.uk
JELLYFIN_SSO_HOST_IP=192.168.50.80
# Non-secret, supplied by the compose_stack role rather than either vars.yml —
# the path it copies ansible/unraid/icon.png to on the host, for the
# net.unraid.docker.icon label. Empty until an icon.png is committed here,
# which just leaves Unraid's question-mark placeholder.
STACK_ICON=/mnt/user/appdata/icons/jellyfin.png

View file

@ -0,0 +1,111 @@
services:
jellyfin:
image: lscr.io/linuxserver/jellyfin:${JELLYFIN_VERSION}
container_name: jellyfin
restart: unless-stopped
# NVENC hardware transcoding needs the container actually started under
# nvidia-container-runtime, not just the NVIDIA_* vars below — the
# runtime is what creates /dev/nvidia* inside the container and puts
# nvidia-smi/CUDA on its PATH. ich777's Nvidia-Driver plugin registers
# `nvidia` as an available Docker runtime, it does NOT make it the
# daemon's default (checked with `docker info --format
# '{{.DefaultRuntime}}'` on nas1: `runc`) — every ich777 dockerMan
# template sets `--runtime=nvidia` explicitly per-container for the same
# reason. Without this line the container silently runs on runc, the two
# env vars reach a container with no GPU to see, and ffmpeg's
# `-init_hw_device cuda=...` fails.
runtime: nvidia
# Unraid's Docker page normally reads icon, WebUI link and console shell
# from the dockerMan template that created the container. A Compose stack
# has no template, so 6.10+ falls back to these labels; without them the
# container renders as a question mark with no WebUI or Console entry.
# See README.md → "Icons on the Unraid Docker page".
labels:
net.unraid.docker.icon: ${STACK_ICON}
net.unraid.docker.webui: "http://[IP]:${JELLYFIN_HTTP_PORT}/"
net.unraid.docker.shell: bash
# Joins Caddy's network by the fixed `proxy` alias; the real network it
# maps to is a per-host decision in vars.yml (JELLYFIN_NETWORK →
# caddy-net). Same alias/override shape as arr and forgejo.
#
# Two wrinkles of running Jellyfin on caddy-net rather than a LAN-bridged
# interface (br0): its NetworkManager can log "only loopback returned"
# because it can't classify the bridge subnet, and the SSO plugin's
# discovery fetch to a private LAN address (Authentik on 192.168.50.80)
# only succeeds with the plugin's "insecure" allow-private-addresses
# option enabled. A br0 container with its own 192.168.50.0/24 address
# needs neither.
networks:
- proxy
# caddy-net's embedded resolver (127.0.0.11) only knows container names
# and forwards everything else to nas1's daemon-level (public) DNS, which
# has no record for the split-horizon name the SSO plugin needs
# (auth.turtlesystems.uk). Point the container at the LAN resolver that
# does. See vars.yml (JELLYFIN_DNS).
dns:
- ${JELLYFIN_DNS}
# Split-horizon DNS answers auth.turtlesystems.uk with the right A record,
# but the public zone also publishes an AAAA with no internal override, so
# the container still sees an unreachable IPv6 address for it and Jellyfin
# can fail closed on that instead of using the A. An IPv4-only /etc/hosts
# entry is read before DNS. Drop this and JELLYFIN_SSO_HOST* once the
# internal zone is authoritative for the name (A-only). See vars.yml.
extra_hosts:
- "${JELLYFIN_SSO_HOST}:${JELLYFIN_SSO_HOST_IP}"
environment:
PUID: ${PUID}
PGID: ${PGID}
TZ: ${TZ}
# NVENC hardware transcoding — paired with `runtime: nvidia` above and
# the note in vars.yml.
NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES}
NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES}
volumes:
- ${JELLYFIN_CONFIG_PATH}:/config
- ${JELLYFIN_TRANSCODE_PATH}:/transcode
# Read-only: Jellyfin only ever reads the library, same as Plex does
# against the same paths.
- ${JELLYFIN_MOVIES_PATH}:/data/movies:ro
- ${JELLYFIN_TV_PATH}:/data/tvshows:ro
- ${JELLYFIN_MUSIC_PATH}:/data/music:ro
- ${JELLYFIN_BOOKS_PATH}:/data/books:ro
- ${JELLYFIN_JELLYBRIDGE_PATH}:/data/JellyBridge
ports:
- "${JELLYFIN_HTTP_PORT}:8096"
# Uncomment for local network discovery (Jellyfin apps auto-finding the
# server) and DLNA. Left off by default — on caddy-net these UDP
# broadcasts don't cross onto the LAN anyway; they only work with the
# container on a LAN-bridged interface.
# - "7359:7359/udp" # auto-discovery
# - "1900:1900/udp" # DLNA
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8096/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
# `external: true` means "attach to a network that already exists; don't
# create it, don't delete it on `down`". Without it Compose would create a
# *new* network named `jellyfin_<key>` — the deploy would still succeed and
# Caddy would silently fail to resolve `jellyfin`. `name:` is what stops the
# project-name prefix being applied.
#
# The local alias is a fixed `proxy` so the compose file stays static; which
# real network it maps to is a per-host decision in vars.yml
# (JELLYFIN_NETWORK). Same shape as arr's `proxy` → `caddy-net` and
# forgejo's `shared` → `unraid_shared`.
#
# caddy-net is defined in Unraid's Docker settings on nas1 rather than by a
# stack in this repo, so nothing here has to deploy before Jellyfin does.
# It must exist on the target host — an absent external network fails the
# deploy outright.
proxy:
name: ${JELLYFIN_NETWORK}
external: true
# Reaching Jellyfin through Caddy means proxying to `jellyfin:8096` — the
# container name on caddy-net — rather than nas1's LAN IP and the published
# port. The `ports:` mapping above is kept for direct access that bypasses
# the proxy; drop it if you don't want 8096 reachable on the LAN.

View file

@ -0,0 +1,97 @@
---
# Unraid-specific overrides, merged over ../../common/vars.yml (these win).
env_defaults:
# Jellyfin's database (SQLite), plugins and library metadata/images. This
# directory *is* the application state, same standing as
# SHELFARR_DATA_PATH in ../../arr — back it up accordingly.
JELLYFIN_CONFIG_PATH: /mnt/user/appdata/jellyfin/config
# Scratch space for transcoding, kept apart from JELLYFIN_CONFIG_PATH.
# Every transcode not served direct writes here, so it's worth pointing at
# something faster than the array if you have a cache pool — unlike
# config, nothing here needs backing up, so it's fine to lose.
JELLYFIN_TRANSCODE_PATH: /mnt/user/appdata/jellyfin/transcode
# CHECK THESE AGAINST YOUR OWN SHARES before the first deploy. Since this
# is running alongside Plex to compare the two, point these at the *same*
# library Plex already reads rather than a second copy — both apps only
# need read access, hence `:ro` in docker-compose.yml.
JELLYFIN_MOVIES_PATH: /mnt/user/Media/Films
JELLYFIN_TV_PATH: /mnt/user/Media/TV
JELLYFIN_MUSIC_PATH: /mnt/user/Media/Music
JELLYFIN_BOOKS_PATH: /mnt/user/Media/Books
JELLYFIN_JELLYBRIDGE_PATH: /mnt/user/Media/JellyBridge
# 99:100 is nobody:users — what Unraid owns share content as. Upstream
# defaults to 1000:1000, which is a normal Linux desktop user and wrong
# here; files written as 1000 are invisible to everything else on the box.
PUID: "99"
PGID: "100"
TZ: Europe/London
# NVENC hardware transcoding. `all` for both is upstream's own recommended
# value, not a placeholder to narrow down — NVIDIA_VISIBLE_DEVICES=all
# exposes every GPU nas1 has to the container (fine for a single-card box)
# and NVIDIA_DRIVER_CAPABILITIES=all pulls in `video`/`compute` alongside
# everything else the base image doesn't otherwise ask for. On their own
# these two vars do nothing — confirmed on nas1 (`docker info --format
# '{{.DefaultRuntime}}'` → `runc`): ich777's Nvidia-Driver plugin registers
# `nvidia` as an available Docker runtime, it does not make it the
# daemon's default, so docker-compose.yml sets `runtime: nvidia` on the
# jellyfin service explicitly — same as every ich777 dockerMan template
# does per-container. Without that line the container runs on runc with no
# GPU device inside it, and ffmpeg's `-init_hw_device cuda=...` fails
# rather than silently falling back to CPU.
NVIDIA_DRIVER_CAPABILITIES: all
NVIDIA_VISIBLE_DEVICES: all
# The stack's Docker network, via the `proxy` alias in docker-compose.yml.
# caddy-net puts Jellyfin behind the reverse proxy and reachable by
# container name; same alias/override pattern as ../../arr and forgejo.
# Two caveats versus a LAN-bridged interface (br0): Jellyfin's
# NetworkManager may log "only loopback returned" here because it can't
# classify the bridge subnet, and the SSO plugin's discovery fetch to
# Authentik's private LAN address (192.168.50.80) only succeeds with the
# plugin's "insecure" allow-private-addresses option turned on.
JELLYFIN_NETWORK: caddy-net
# LAN resolver the container forwards DNS to, via `dns:` in
# docker-compose.yml. caddy-net's embedded resolver only knows container
# names and hands everything else to nas1's daemon-level (public) DNS,
# which has no record for the split-horizon name the SSO plugin needs
# (auth.turtlesystems.uk). Point it at the internal resolver instead.
JELLYFIN_DNS: 192.168.50.251
# SSO provider (Authentik) hostname, pinned to an IPv4-only /etc/hosts
# entry via `extra_hosts` in docker-compose.yml. Split-horizon DNS already
# answers this name with the right A record (JELLYFIN_SSO_HOST_IP), but the
# public zone still publishes an AAAA with no internal override, and
# Jellyfin's SSO plugin can fail closed on the unreachable IPv6 address
# rather than falling back to the A. Pinning it here sidesteps that. Remove
# both vars (and the extra_hosts block) once the internal zone is
# authoritative for this name — A-only, no AAAA fall-through.
JELLYFIN_SSO_HOST: auth.turtlesystems.uk
JELLYFIN_SSO_HOST_IP: 192.168.50.80
# --- Unraid Docker page presentation (net.unraid.docker.* labels) --------
#
# STACK_ICON is normally left to the compose_stack role, which points it at
# /mnt/user/appdata/icons/<stack>.png when this directory contains an
# icon.png — none is committed here yet (see ../../common/vars.yml).
# Uncomment to use a hosted icon instead:
#
# STACK_ICON: https://example.org/jellyfin.png
#
# PNG only — Unraid renders its placeholder for SVG and nothing at all for
# WebP, and a URL it can't reach when the Docker page renders fails the
# same way, which is the argument for committing the file.
# --- Hardware transcoding ---------------------------------------------
#
# nas1 has an NVIDIA card, so it's NVENC via the two vars above rather than
# Intel/AMD VAAPI. If Jellyfin ever moves to a box without one, VAAPI is
# the equivalent path there: drop the two NVIDIA_* vars, add
# `devices: [/dev/dri:/dev/dri]` to the jellyfin service in
# docker-compose.yml, and add the render group's GID
# (`getent group render`) via `group_add:`.

View file

@ -0,0 +1,36 @@
---
# Portable config — values that hold regardless of where Jellyfin runs.
# Platform-specific values (filesystem paths, the PUID/PGID the container
# writes as) live in ../ansible/<platform>/vars.yml and are merged over these
# at deploy time.
env_defaults:
# LinuxServer tags are the upstream Jellyfin version plus their own build
# suffix (e.g. `10.10.7-ls145`), which can't be derived from the release
# number — read it off the registry and replace this with a full tag before
# relying on this in anything but a throwaway test. `latest` for now, same
# as PROWLARR_VERSION in ../../arr/common/vars.yml — check
# https://github.com/linuxserver/docker-jellyfin/pkgs/container/jellyfin
JELLYFIN_VERSION: latest
# Published port. 8096 is upstream's default and what the container listens
# on internally; kept the same on the host so the WebUI label, the LAN URL
# and the docs all read alike.
JELLYFIN_HTTP_PORT: "8096"
# No Vault secrets today — Jellyfin's admin account is set up through its
# own first-run web wizard rather than an env var, and there's no API key
# or session secret this deployment pins the way Shelfarr's
# SECRET_KEY_BASE or Prowlarr's PROWLARR_API_KEY are. The compose_stack
# role still looks up `homelab/jellyfin` unconditionally (see
# ../../arr/ansible/unraid/vars.yml's equivalent note — there isn't one,
# because arr has real secrets; this is the first app that doesn't), so the
# KV path has to exist before the first deploy even if it holds nothing —
# `vault kv put kv/homelab/jellyfin placeholder=unused` is enough. Revisit
# if a plugin (hardware-key SSO, a remote-access token) needs one later.
# STACK_ICON is supplied by the compose_stack role, from a committed
# ../ansible/unraid/icon.png — not set here, and none is committed yet, so
# the Docker page shows Unraid's placeholder until one is added. Uncomment
# to use a hosted icon instead:
#
# STACK_ICON: https://example.org/jellyfin.png

View file

@ -0,0 +1,11 @@
# terraform/ — jellyfin
Not built, and not expected to be. Terraform's only job in this repo is
creating Proxmox LXCs; Jellyfin runs as a Compose stack on nas1, where the
host already exists.
This folder is a placeholder so the app keeps the same
`common/` + `ansible/` + `terraform/` shape as every other one. If Jellyfin
ever moves to Proxmox it would need an LXC defined here and wired into
`build/config/terraform/main.tf` — see `../ansible/proxmox/README.md` first
for why that move is less straightforward than it looks.

View file

@ -0,0 +1,20 @@
# ansible/proxmox/ — watchstate
Not built. WatchState runs as a Compose stack on nas1 (`../unraid/`) — this
folder exists because every app keeps the same shape whether or not both
platforms are in use.
It's also a poor fit for a move today for the same reason as Jellyfin (see
`../../jellyfin/ansible/proxmox/README.md`): WatchState is here to sync watch
state between the Plex and Jellyfin instances that are being compared
side-by-side on nas1, so it wants to sit on the same `caddy-net` as Jellyfin
and next to Plex on the LAN. From a Proxmox LXC that's an extra routing hop
for no benefit while the trial is still running. Unlike Jellyfin there's no
media-share or GPU complication — WatchState only ever talks to the two media
servers over HTTP — so a later move would be a straight `install.yml` +
`terraform/` build.
What wouldn't change: `../../common/vars.yml` already holds the portable
config (version, published port, timezone), and the Vault path stays
`homelab/watchstate`. Only the appdata path and the UID/GID are
Unraid-specific, and those live in `../unraid/vars.yml`.

View file

@ -0,0 +1,37 @@
# Reference only — real values are rendered by Ansible from vars.yml (non-
# secret) and Vault path homelab/watchstate (see ../../common/vars.yml — there
# are no real secrets yet, but the path still has to exist). Do not fill this
# in and deploy it directly.
# Non-secret (see ../../common/vars.yml)
WATCHSTATE_VERSION=latest
# Host-side published port. The container is always 8080 internally. Not 8080
# here on purpose — pick something free on nas1.
WATCHSTATE_HTTP_PORT=8580
# Non-secret (see vars.yml). This directory is the whole of WatchState's
# state — SQLite DB, backend configs, logs. Must be owned by 99:100.
WATCHSTATE_CONFIG_PATH=/mnt/user/appdata/watchstate/config
PUID=99
PGID=100
TZ=Europe/London
# Docker network to join, and it must already exist on the target host —
# Compose declares it `external`, so it will not create it. Defined in
# Unraid's Docker settings, not by any stack in this repo.
WATCHSTATE_NETWORK=caddy-net
# Non-secret, supplied by the compose_stack role rather than either vars.yml —
# the path it copies ansible/unraid/icon.png to on the host, for the
# net.unraid.docker.icon label. Empty until an icon.png is committed here,
# which just leaves Unraid's question-mark placeholder.
STACK_ICON=/mnt/user/appdata/icons/watchstate.png
# --- Optional, all commented out by default -------------------------------
# Turn off the task scheduler / built-in cache server (both on by default).
# DISABLE_CRON=1
# DISABLE_CACHE=1
# Only if WatchState is ever fronted by Caddy + Authentik forward-auth.
# Setting both disables all of WatchState's internal auth.
# WS_TRUST_PROXY=true
# WS_TRUST_LOCAL=true

View file

@ -0,0 +1,100 @@
services:
watchstate:
image: ghcr.io/arabcoders/watchstate:${WATCHSTATE_VERSION}
container_name: watchstate
restart: unless-stopped
# The image runs rootless and drops privileges to this uid:gid — it is a
# `user:` directive, not PUID/PGID env vars like the LinuxServer images
# (arr, jellyfin). It must match the owner of the appdata bind below or
# the container exits on start, unable to write /config. On Unraid that is
# 99:100 (nobody:users) — upstream's own Unraid note says the same.
#
# WatchState won't chown /config itself the way the LinuxServer images do,
# so the `appdata:` block on this stack's host_vars entry pre-creates the
# bind directory owned by 99:100 before `docker compose up` — otherwise
# Docker makes it root:root on first start and the container exits.
user: "${PUID}:${PGID}"
# Unraid's Docker page normally reads icon, WebUI link and console shell
# from the dockerMan template that created the container. A Compose stack
# has no template, so 6.10+ falls back to these labels; without them the
# container renders as a question mark with no WebUI or Console entry.
# See README.md → "Icons on the Unraid Docker page".
#
# `sh`, not `bash`: the image is Alpine-based (frankenphp on Alpine).
labels:
net.unraid.docker.icon: ${STACK_ICON}
net.unraid.docker.webui: "http://[IP]:${WATCHSTATE_HTTP_PORT}/"
net.unraid.docker.shell: sh
# Joins Caddy's network by the fixed `proxy` alias, same shape as arr and
# jellyfin; the real network it maps to is a per-host decision in vars.yml
# (WATCHSTATE_NETWORK → caddy-net). Caddy then proxies to
# `watchstate:8080` — the container name on caddy-net — rather than nas1's
# LAN IP and the published port.
#
# This also puts Jellyfin one hop away: WatchState reaches it as
# `http://jellyfin:8096` when adding it as a backend. Plex and anything
# else off caddy-net is addressed by nas1's LAN IP and its published port
# (e.g. http://192.168.50.1:32400) — a runtime concern configured in the
# WebUI, not here.
networks:
- proxy
environment:
# Image default is UTC; TZ comes from vars.yml. Both are named here
# because Compose reads .env only to interpolate `${...}` in this file —
# a var that nothing references never reaches the container.
WS_TZ: ${TZ}
TZ: ${TZ}
# The task scheduler (import every hour, export every 90 min) and the
# built-in cache server are both on by default. Uncomment to turn either
# off — e.g. DISABLE_CRON while doing the initial one-off backend sync by
# hand, before enabling the tasks on the Tasks page.
# DISABLE_CRON: "1"
# DISABLE_CACHE: "1"
# --- Reverse-proxy auth (optional) --------------------------------------
# WatchState has its own internal auth (the first-run system user). If it
# is ever fronted by Caddy + Authentik forward-auth instead, set these so
# it trusts the proxy rather than double-prompting. Setting BOTH disables
# all internal auth — only do that once the external layer is definitely
# in front of it.
# WS_TRUST_PROXY: "true"
# WS_TRUST_LOCAL: "true"
volumes:
# SQLite database (db/watchstate_v01.db), backend configs, guid.yaml and
# logs — this directory *is* the application state, same standing as
# arr's SHELFARR_DATA_PATH. Back it up accordingly.
- ${WATCHSTATE_CONFIG_PATH}:/config
ports:
- "${WATCHSTATE_HTTP_PORT}:8080"
# Restated from the image's own HEALTHCHECK. The port here is the fixed
# internal 8080, never WATCHSTATE_HTTP_PORT.
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/v1/api/system/healthcheck"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
# `external: true` means "attach to a network that already exists; don't
# create it, don't delete it on `down`". Without it Compose would create a
# *new* network named `watchstate_<key>` — the deploy would still succeed and
# Caddy would silently fail to resolve `watchstate`. `name:` is what stops
# the project-name prefix being applied.
#
# The local alias is a fixed `proxy` so the compose file stays static; which
# real network it maps to is a per-host decision in vars.yml
# (WATCHSTATE_NETWORK). Same shape as arr's `proxy` → `caddy-net` and
# jellyfin's.
#
# caddy-net is defined in Unraid's Docker settings on nas1 rather than by a
# stack in this repo, so nothing here has to deploy before WatchState does.
# It must exist on the target host — an absent external network fails the
# deploy outright.
proxy:
name: ${WATCHSTATE_NETWORK}
external: true
# Reaching WatchState through Caddy means proxying to `watchstate:8080` — the
# container name on caddy-net — rather than nas1's LAN IP and the published
# port. The `ports:` mapping above is kept for direct access that bypasses the
# proxy; drop it if you don't want WATCHSTATE_HTTP_PORT reachable on the LAN.

View file

@ -0,0 +1,40 @@
---
# Unraid-specific overrides, merged over ../../common/vars.yml (these win).
env_defaults:
# WatchState's SQLite database, backend configuration, guid.yaml and logs.
# This directory *is* the application state, same standing as arr's
# SHELFARR_DATA_PATH — back it up accordingly. Nothing else lives under
# /mnt/user/appdata/watchstate, so the stack folder and this one line up.
WATCHSTATE_CONFIG_PATH: /mnt/user/appdata/watchstate/config
# 99:100 is nobody:users — what Unraid owns share content as, and what the
# container must run as to write WATCHSTATE_CONFIG_PATH (see the `user:`
# note in docker-compose.yml). Upstream defaults to 1000:1000, a normal
# Linux desktop user, which can't write appdata here.
#
# These feed the `user:` directive, not PUID/PGID env vars — WatchState is
# not a LinuxServer image. Named PUID/PGID only to match arr and jellyfin.
PUID: "99"
PGID: "100"
TZ: Europe/London
# The stack's Docker network, via the `proxy` alias in docker-compose.yml.
# caddy-net puts WatchState behind the reverse proxy and reachable by
# container name, and puts Jellyfin one hop away (`http://jellyfin:8096`)
# for adding it as a backend. Same alias/override pattern as ../../arr and
# ../../jellyfin.
WATCHSTATE_NETWORK: caddy-net
# --- Unraid Docker page presentation (net.unraid.docker.* labels) --------
#
# STACK_ICON is normally left to the compose_stack role, which points it at
# /mnt/user/appdata/icons/<stack>.png when this directory contains an
# icon.png — none is committed here yet (see ../../common/vars.yml).
# Uncomment to use a hosted icon instead:
#
# STACK_ICON: https://example.org/watchstate.png
#
# PNG only — Unraid renders its placeholder for SVG and nothing at all for
# WebP, and a URL it can't reach when the Docker page renders fails the
# same way, which is the argument for committing the file.

View file

@ -0,0 +1,45 @@
---
# Portable config — values that hold regardless of where WatchState runs.
# Platform-specific values (filesystem paths, the UID/GID the container writes
# as) live in ../ansible/<platform>/vars.yml and are merged over these at
# deploy time.
env_defaults:
# Upstream publishes a rolling `latest` plus dated `YYYYMMDD` tags; there is
# no semver release tag to pin to. `latest` for now, same as
# JELLYFIN_VERSION in ../../jellyfin/common/vars.yml — read a dated tag off
# https://github.com/ArabCoders/watchstate/pkgs/container/watchstate and
# replace this before relying on it for anything but a throwaway test.
WATCHSTATE_VERSION: latest
# Published port on the host. The container always listens on 8080
# internally (EXPOSE 8080, and the image's own HEALTHCHECK hard-codes it),
# so that side of the mapping in docker-compose.yml is fixed — only this
# one is configurable.
#
# Deliberately NOT 8080, unlike Jellyfin keeping 8096 on both sides: 8080 is
# a high-collision port on an Unraid box (qBittorrent, SABnzbd and friends
# default to it), and this repo has no view of what else nas1 publishes. Set
# to whatever is free; the WebUI label and the LAN URL follow it.
WATCHSTATE_HTTP_PORT: "8580"
# Timezone WatchState stamps its scheduled-task run times and logs with.
# The image defaults WS_TZ to UTC; the real value is Unraid-specific and set
# in ../ansible/unraid/vars.yml (TZ), read here via WS_TZ in
# docker-compose.yml's environment block.
# No Vault secrets today — WatchState's first-run flow creates a system user
# through its own web wizard, and every backend (Plex, Jellyfin) is added
# afterwards from the WebUI or `docker exec -ti watchstate console`, with its
# API token stored in /config, not injected as an env var. Same situation as
# Jellyfin: the compose_stack role still looks up `homelab/watchstate`
# unconditionally, so the KV path has to exist before the first deploy even
# if it holds nothing — `vault kv put kv/homelab/watchstate placeholder=unused`
# is enough. Revisit if a future version grows a pre-settable API key or an
# OIDC secret (see docs/vault-secrets.md).
# STACK_ICON is supplied by the compose_stack role, from a committed
# ../ansible/unraid/icon.png — not set here, and none is committed yet, so
# the Docker page shows Unraid's placeholder until one is added. Uncomment
# to use a hosted icon instead:
#
# STACK_ICON: https://example.org/watchstate.png

View file

@ -0,0 +1,10 @@
# terraform/ — watchstate
Not built, and not expected to be. Terraform's only job in this repo is
creating Proxmox LXCs; WatchState runs as a Compose stack on nas1, where the
host already exists.
This folder is a placeholder so the app keeps the same
`common/` + `ansible/` + `terraform/` shape as every other one. If WatchState
ever moves to Proxmox it would need an LXC defined here and wired into
`build/config/terraform/main.tf` — see `../ansible/proxmox/README.md` first.