/
/
/
Ansible role that deployes services on my runner machine
1---
2# Runner Services Role Defaults
3# Multi-service container deployment with NFS integration
4
5# ==============================================================================
6# GENERAL SETTINGS
7# ==============================================================================
8runner_enabled: true
9# Legacy variables - no longer used with consolidated structure
10# runner_docker_dir: "/docker/runner" # Now each service uses /docker/[service]
11# runner_data_dir: "/docker/runner-data" # Now consolidated into each service directory
12runner_nfs_mount_dir: "/mnt/docker"
13runner_snapshot_dir: "{{ runner_nfs_mount_dir }}/frigate/snapshots"
14runner_user: "{{ ansible_user }}"
15runner_group: "users"
16runner_uid: 1000
17runner_gid: 1000
18
19# Timezone configuration
20runner_timezone: "{{ system_timezone }}"
21
22# Docker network for runner services
23runner_docker_network: "runner-network"
24runner_network_subnet: "172.20.0.0/16"
25
26# NFS Configuration
27runner_nfs_enabled: true
28runner_nas_host: "{{ vault_storage.nas_host | default('storage.home') }}"
29runner_nfs_options: "nfsvers=4.1,proto=tcp,hard,timeo=600,retrans=2,rsize=16777216,wsize=16777216,nconnect=8,noatime,_netdev"
30
31# ==============================================================================
32# NFS MOUNT CONFIGURATION
33# ==============================================================================
34runner_nfs_mounts:
35 - name: "frigate"
36 local_path: "{{ runner_nfs_mount_dir }}/frigate"
37 nfs_path: "/mnt/rstorage/cctv-data"
38 host: "{{ runner_nas_host }}"
39 options: "{{ runner_nfs_options }}"
40
41 - name: "immich"
42 local_path: "{{ runner_nfs_mount_dir }}/immich"
43 nfs_path: "/mnt/rstorage/media/pictures"
44 host: "{{ runner_nas_host }}"
45 options: "{{ runner_nfs_options }}"
46
47 - name: "forgejo"
48 local_path: "{{ runner_nfs_mount_dir }}/forgejo"
49 nfs_path: "/mnt/rstorage/code-repo"
50 host: "{{ runner_nas_host }}"
51 options: "{{ runner_nfs_options }}"
52
53 - name: "harbor"
54 local_path: "{{ runner_nfs_mount_dir }}/harbor"
55 nfs_path: "/mnt/rstorage/registry-data"
56 host: "{{ runner_nas_host }}"
57 options: "{{ runner_nfs_options }}"
58
59 - name: "cvat"
60 local_path: "{{ runner_nfs_mount_dir }}/cvat"
61 nfs_path: "/mnt/rstorage/cvat-datasets"
62 host: "{{ runner_nas_host }}"
63 options: "{{ runner_nfs_options }}"
64
65 # D&D session agent: ambience, effects, recordings, transcripts and the
66 # campaign knowledge base all live under one mount.
67 - name: "dnd"
68 local_path: "{{ runner_nfs_mount_dir }}/dnd"
69 nfs_path: "/mnt/rstorage/media/dnd"
70 host: "{{ runner_nas_host }}"
71 options: "{{ runner_nfs_options }}"
72
73# ==============================================================================
74# SERVICE CONFIGURATIONS
75# ==============================================================================
76
77# LLM Stack - Unified Local AI Infrastructure
78llm_stack_enabled: true
79llm_stack_config_dir: "/docker/llm-stack"
80
81# Unified LLM Stack Configuration
82llm_stack_ollama_port: 9000
83llm_stack_openwebui_port: 9001
84llm_stack_litellm_port: 9002
85
86# Ollama - Local LLM Server
87llm_stack_ollama_data_dir: "{{ llm_stack_config_dir }}/ollama/data"
88# GPU configuration uses global gpu_enabled parameter
89llm_stack_ollama_default_model: "qwen3:14b"
90# 4h rather than the default 1h: a post step (the chronicle with the thread
91# pass) now runs longer than an hour, and the keep-alive expiring mid-step
92# forces a model reload -- and the load path is where Ollama's scheduler has
93# wedged in production. The Chamberlain unloads the model explicitly before
94# every GPU-hungry step, so a long keep-alive costs nothing: the explicit
95# unloads are what the pipeline relies on, not this.
96llm_stack_ollama_keep_alive: "4h"
97llm_stack_ollama_log_level: "info"
98# Ollama truncates silently past this, so it must be large enough for a whole
99# session transcript. Bounded by VRAM: the KV cache sits beside the model, and
100# 14b at ~9GB leaves little of the 3060's 12GB. See the note in llm-stack.env.j2.
101#
102# Raised 16384 -> 24576 on 2026-08-18, spending the headroom `q8_0` freed. The
103# KV cache is preallocated for this many tokens when the model loads, so the
104# whole cost is paid up front and shows in `nvidia-smi` before any prompt is
105# sent.
106#
107# **24576 is the measured ceiling on this card, not a guess.** 28672 was tried
108# first and did NOT fit: `ollama ps` reported `9%/91% CPU/GPU` at 12 GB, the
109# silent half-speed split that pitfall-era f16 also produced. Nothing errored
110# and the request succeeded -- only `ollama ps` showed it. Measured, model
111# resident, one prompt each:
112#
113# 16384 ~9.7 GB 100% GPU 10,091 MiB used
114# 20480 10 GB 100% GPU 10,415 MiB used
115# 24576 11 GB 100% GPU 10,763 MiB used <- here
116# 28672 12 GB 9%/91% CPU split, half speed
117#
118# So the cache costs roughly 320 MiB per 4096 tokens and the cliff sits just
119# above 24576. **Verify after any change, do not assume**: the check is
120# `docker exec ollama ollama ps` showing 100% GPU, and the server log carrying
121# no "offloading N layers to CPU" line. A larger model, or a second model
122# resident, moves this ceiling down.
123#
124# What this does NOT buy: a longer chapter. The model's output ceiling is
125# ~2,000 completion tokens whatever the context is (pitfall #13), so this is
126# spendable only on what the model *reads* -- the gazetteer, the sheets, the
127# state -- never on what it must *write*. `WEAVE_SCENES`/`WEAVE_BUDGET` stay
128# where they were measured.
129llm_stack_ollama_context_length: 24576
130
131# Pull declared models on deploy. Only models absent from Ollama are fetched,
132# so re-runs are cheap. Set false to manage the model set by hand.
133llm_stack_pull_models: true
134llm_stack_model_pull_timeout: 3600
135
136# Model lineup
137llm_stack_models:
138 - name: "qwen3:14b"
139 role: "default"
140 description: "General-purpose daily driver"
141 - name: "phi4:14b"
142 role: "coder"
143 description: "Coding and analytical tasks"
144 - name: "qwen2.5-coder:14b"
145 role: "coder"
146 description: "Coding specialist (Qwen 2.5 generation)"
147 - name: "deepseek-r1:14b"
148 role: "reasoning"
149 description: "Complex reasoning and chain-of-thought"
150 - name: "qwen3:8b"
151 role: "fast"
152 description: "Lightweight fast tasks"
153 - name: "qwen3:4b"
154 role: "minimal"
155 description: "Quick lightweight tasks; D&D live cue selection"
156 - name: "qwen2.5vl:7b"
157 role: "vision"
158 description: "Battlemap frame understanding; Mealie recipe-from-photo imports"
159 # Images only work through ollama_chat; see llm-stack-litellm-config.yml.j2.
160 provider: "ollama_chat"
161
162# OpenWebUI - Web Interface for Ollama
163llm_stack_openwebui_data_dir: "{{ llm_stack_config_dir }}/openwebui/data"
164llm_stack_openwebui_name: "Local AI Assistant"
165llm_stack_openwebui_description: "Self-hosted AI chat interface"
166llm_stack_openwebui_author: "Home Assistant"
167llm_stack_openwebui_default_models: "qwen3:14b"
168llm_stack_openwebui_log_level: "info"
169
170# LiteLLM - Unified LLM Proxy
171llm_stack_litellm_data_dir: "{{ llm_stack_config_dir }}/litellm/data"
172llm_stack_litellm_streaming_enabled: true
173llm_stack_litellm_log_level: "info"
174
175# =============================================================================
176# D&D Session Agent - The Thaumaturgeon
177# One compose project, one switch: dnd_enabled: false removes all of it.
178# =============================================================================
179dnd_enabled: false
180dnd_config_dir: "/docker/dnd"
181
182# Every D&D image comes from Harbor. Shared by the agent, the post-processor
183# and the table server so there is one place to change the registry.
184dnd_registry: "{{ vault_runner.harbor_hostname | default('ys-registry.com') }}"
185dnd_registry_project: "docker"
186# Ansible runs as root, which has its own docker credential store, so a login
187# is needed even though this host serves the registry.
188dnd_registry_username: "{{ (vault_dnd_table | default({})).registry_username | default('admin') }}"
189dnd_registry_password: >-
190 {{ (vault_dnd_table | default({})).registry_password
191 | default(vault_runner.harbor_admin_password) }}
192
193# Ambience, effects, recordings, transcripts and the campaign knowledge base
194# all live on the NAS under one mount.
195dnd_media_dir: "{{ runner_nfs_mount_dir }}/dnd"
196
197# Files written here are read by people and by the storage host, so the
198# container runs as the storage owner rather than root.
199dnd_uid: 1000
200dnd_gid: 100
201
202# There is deliberately no dnd_room. Rooms are derived per session by the
203# Chamberlain as <campaign-slug>-<nnn>; one fixed name would put every session
204# of every campaign in the same room.
205dnd_avatar_url: "{{ 'https://' ~ livekit_domain ~ '/' ~ livekit_agent_avatar_filename }}"
206
207# The node at the table, running the `dnd-atmosphere` receiver from the
208# audio-client role. It takes its sound card while a session is live and gives
209# it back to snapcast afterwards, so the rest of the house keeps its music.
210dnd_audio_host: "hifi-node-dining.home"
211dnd_audio_port: 9020
212
213# observe until the cue detection has been watched over real sessions. The
214# timeline is written either way; only playing is withheld, which is what makes
215# a first session safe to run.
216#
217# This governs the LIGHTS as well as the sound: the driver records every
218# decision and touches no device unless the mode is `live`. A room that quietly
219# started flickering during a session nobody meant to be live is exactly the
220# surprise this exists to prevent.
221dnd_atmosphere_mode: "observe"
222
223# Mix the session's own audio into the room, so a replayed run can be heard.
224#
225# **Off, and it must stay off for a real evening** -- the room already hears
226# itself and mixing it back is feedback, not monitoring. This exists for
227# test-feed runs, where the "room" is a recording of somebody else's table and
228# there is nothing to feed back into.
229#
230# It answers a question the timeline cannot: whether a cue lands ON the beat.
231# The timeline records that a cue fired 3 s after an utterance began, and
232# utterances run 10-30 s, so that number cannot tell "on time" from "three
233# sentences late". Only listening can.
234#
235# **1.0, and 0.35 was measured too quiet to hear.**
236#
237# The instinct is to duck the session under the atmosphere -- it is the thing
238# being monitored, not presented. But the source material is already quiet:
239# measured on the C2E23 recordings, rms 703 (**2.1% of full scale**) with
240# peaks to 28219. At 0.35 that becomes rms 246, which is inaudible under a bed
241# at 0.45. On the wire it measured -35 dBFS and the user could not hear the
242# table at all.
243#
244# 1.0 is also the ceiling: the peaks are already at 86% of full scale, so
245# anything above 1.0 clips them rather than making speech louder.
246#
247# A recording with more headroom would want less. Measure it -- `audioop.rms`
248# over the middle of the WAV -- rather than guessing from how it sounds in a
249# player, which applies its own gain.
250dnd_monitor_session: false
251
252# Publish the atmosphere into the conference for remote listeners. Off by
253# default: it changes what every remote client hears, and a client that has
254# not been taught to unsubscribe would play it on top of the room speaker.
255dnd_publish_atmosphere: false
256dnd_monitor_gain: 1.0
257
258# Whether the room's Shelly dimmers are driven directly over their local HTTP
259# API. Off means the agent runs the whole lighting layer, writes its decisions
260# to the timeline, and lights nothing -- which is also what a deployment with no
261# lights should do.
262#
263# `lights.yml` is seeded onto the share once and never overwritten, because it
264# is a file that gets tuned between sessions. The addresses of the five devices
265# live in it rather than here for the same reason: a light that gets
266# re-addressed on the network should not need a deploy to follow it.
267dnd_lights_enabled: true
268
269# Push the repo's copies of the tuned configs over whatever is on the share.
270#
271# **False by default, and that is the safe direction.** `cues.yml`,
272# `atmosphere.yml` and `lights.yml` are tuned between sessions by editing the
273# share, so a routine deploy must not reach in and undo an evening's work --
274# the symptom would be the room behaving as it did last month, and nothing
275# would point at Ansible.
276#
277# Set it true while a value is actively being changed from the repo side, as
278# with the judge model during the 2026-09 evaluation runs:
279#
280# ansible-playbook runner.yml --tags dnd -e dnd_config_force=true
281#
282# The role takes a dated copy of each file first, so a forced push is
283# recoverable.
284dnd_config_force: false
285
286# The test feeder: publishes a recorded episode into a session's room as two
287# participants, so the atmosphere and lighting layers can be tuned against real
288# material before an evening is spent on them.
289#
290# Off by default. It is test scaffolding on the one always-on stack, it sits
291# behind its own compose profile as well as this flag, and nothing starts it
292# implicitly -- the belt and braces are deliberate, because a feeder that
293# started on its own would publish an episode into a room with people in it.
294dnd_testfeed_enabled: false
295
296# Where the episodes live. Each is a directory holding processed/matt.flac,
297# processed/group.flac and their transcripts -- the DM and the table as two
298# sample-aligned tracks, which is what lets a run exercise the real
299# architecture rather than simulate it.
300#
301# **A container path, not a host path.** The Chamberlain reads this directory
302# to list what can be fed, and passes the same string to the feeder as
303# `--episode`; both see the media root bind-mounted at /media/dnd, so the value
304# has to be expressed in those terms or the Chamberlain would look for a host
305# path that does not exist inside it. Same invariant the compose file relies on
306# for every other session path.
307dnd_testfeed_dir: "/media/dnd/test"
308
309# The same directory as the host sees it, for the bind mount itself. These two
310# are the same place named twice, which is exactly the pair that would drift if
311# either were written inline.
312dnd_testfeed_host_dir: "{{ dnd_media_dir }}/test"
313
314# How long a feed may run before it is given up on. A full episode is about
315# three and a half hours at 1x, which is the intended speed -- the deployed
316# pipeline is not clock-scaled, so publishing faster does not make it think
317# faster. Four hours covers the longest episode with room to spare.
318dnd_feed_timeout: 14400
319
320# Pulled from Harbor. CI builds on every push to main and tags by commit SHA
321# plus `latest`; there are no release versions to maintain.
322#
323# Deploys follow `latest`. To pin a known-good set instead, set dnd_image_tag
324# to the parent repository's SHA - its pin workflow re-tags every component's
325# image with that one tag, so a single value reverts the whole system.
326dnd_image_tag: "latest"
327dnd_agent_image: >-
328 {{ dnd_registry }}/{{ dnd_registry_project }}/dnd-agent:{{ dnd_image_tag }}
329dnd_post_image: >-
330 {{ dnd_registry }}/{{ dnd_registry_project }}/dnd-post:{{ dnd_image_tag }}
331
332# ------------------------------------------------------------------------------
333# THE CHAMBERLAIN - session and campaign orchestration
334# ------------------------------------------------------------------------------
335# The only continuously running part of the D&D stack. It starts and stops the
336# table server, the agent and the camera recorder around a session, so those are
337# all behind compose profiles and this is not.
338#
339# Deliberately part of the `dnd` compose project rather than its own: it drives
340# that project by name, and a separate project would mean it could not.
341dnd_chamberlain_image: >-
342 {{ dnd_registry }}/{{ dnd_registry_project }}/dnd-chamberlain:{{ dnd_image_tag }}
343# 8095: 8090 is taken by stirling-pdf on this runner. Kept near the other D&D
344# ports (8081 table server, 9010 stt) rather than moved somewhere unrelated.
345chamberlain_http_port: 8095
346
347
348# ==============================================================================
349# THE ALMANAC -- per-player character curation
350# ==============================================================================
351# A separate service from the Chamberlain, on purpose. The Chamberlain holds
352# the Docker socket and has no authentication by decision; the Almanac is the
353# one players reach, so its blast radius is character data and nothing more.
354# See docs almanac-plan.md.
355dnd_almanac_enabled: true
356dnd_almanac_image: >-
357 {{ dnd_registry }}/{{ dnd_registry_project }}/dnd-almanac:{{ dnd_image_tag }}
358
359# 8099: adjacent to the Chamberlain's 8095 and clear of the table server (8081),
360# the camera preview (8096) and stt (9010).
361dnd_almanac_port: 8099
362
363# The SQLite database and the vendored SRD corpus.
364#
365# **This was wrong until 2026-10-01 and the comment said otherwise.** It read
366# `{{ runner_nfs_mount_dir }}/almanac`, which resolves to `/mnt/docker/almanac`
367# -- a *sibling* of the NFS mounts, not inside one. `/mnt/docker` is a plain
368# local directory; only `/mnt/docker/dnd` is `storage.home:/mnt/rstorage/media/dnd`.
369# So the database sat on the runner's local NVMe, covered by no backup at all,
370# while this comment claimed it was "on the NFS mount beside the campaigns".
371# Found by `stat -c %m`, not by reading: the path looks like an NFS path.
372#
373# It is also outside the backup role's discovery, which lists `/docker/*` only.
374# Being under the dnd NFS tree fixes both at once -- that tree is the NAS's,
375# and the NAS is what has snapshots.
376#
377# Still NOT inside dnd_media_dir's campaign directories: the container had no
378# path into those until phase 3, and keeping the database out of the tree it
379# projects into is the separation that made phase 3 reviewable.
380#
381# **Changing this does not move the existing database.** See
382# docs/technical-debt.md for the migration: stop the container, copy
383# /mnt/docker/almanac/ to /mnt/docker/dnd/almanac/, deploy, verify the row
384# counts, then remove the old directory.
385dnd_almanac_data_dir: "{{ dnd_media_dir }}/almanac"
386
387# The SRD reference corpus for the editor's spell, class and equipment
388# pickers: 5e-bits/5e-database, MIT, ~3.8 MB of JSON. SRD-scoped, so it is a
389# convenience over free text and never a constraint on it -- a character using
390# non-SRD content still types it in by hand.
391#
392# Fetched once, when the directory is empty. Set false on a runner with no
393# route to GitHub; the editor degrades to plain fields.
394dnd_almanac_srd_fetch: true
395dnd_almanac_srd_repo: "https://github.com/5e-bits/5e-database.git"
396
397# The shared secret the Chamberlain presents to the Almanac's admin API.
398#
399# **Both services need the same value**, which is why it lives here rather
400# than in either service's own block. Empty closes the machine path entirely:
401# only a signed-in administrator can then administer, and the Chamberlain's
402# People page reports that it cannot reach the Almanac rather than silently
403# doing nothing.
404#
405# Override in the vault for a real deployment; the default is a placeholder
406# that is obviously not a secret.
407dnd_almanac_admin_token: "{{ (vault_dnd_almanac | default({})).admin_token | default('change-me-almanac-token') }}"
408
409# Off because this is served over plain HTTP on Tailscale. Turn it on when it
410# is fronted by TLS, or the session cookie will not be sent at all.
411dnd_almanac_secure_cookie: false
412
413# Where a browser reaches the Almanac, for the link on the Chamberlain's cast
414# view. Not the same as the internal URL, which is a Docker network name: set
415# this to whatever a player types -- the Tailscale name, or the reverse proxy.
416# Empty is safe; the UI just names the Almanac without linking to it.
417dnd_almanac_public_url: "http://{{ ansible_host | default('runner.home') }}:{{ dnd_almanac_port }}"
418
419# Phase 3: mount the media root and let the Almanac project its characters into
420# the campaign directories.
421#
422# **Its own flag rather than implied by the service being enabled.** This is the
423# one path by which a player-facing service writes outside its own database, so
424# turning it on should be a decision somebody made rather than a side effect of
425# a deploy. Off means projection answers 503 and says why.
426#
427# Before turning it on for a campaign, look at the dry run:
428#
429# GET /api/almanac/campaigns/<slug>/projection
430#
431# Expect `reformat` to list every sheet the first time -- those files were
432# hand-ordered and projection emits `CharacterSheet.to_dict`'s order. `update`
433# is the field that means a real content change.
434#
435# **Turned on 2026-10-01**, after the phase 3 exit criterion was met: a post
436# run consuming projected files. Measured rather than inferred --
437# `dnd_thaumaturgeon_post/tests/test_projected_campaign.py` projects the ten
438# real sheets and asserts `Campaign.context()` is identical, which is the
439# campaign half of the chronicler's prompt. Identical input, identical
440# chapter, up to the model's own sampling.
441#
442# The dry run against the live data, on the same day:
443#
444# critical-role-c2-audio update=0 reformat=10 orphans=0 (10 chars)
445# critical-role-c2 update=0 reformat=0 orphans=10 (0 chars)
446# critical-role-c2-e23 update=0 reformat=0 orphans=10 (0 chars)
447# critical-role-c2-mighty-nein update=0 reformat=0 orphans=7 (0 chars)
448#
449# `update=0` everywhere is the number that mattered. Projecting into a copy of
450# the real directory produced ten semantically identical sheets, and the
451# roster merge preserved `labels` (9), `voices` and `table_label` while
452# participants grew 2 -> 11 with none lost. The three campaigns the Almanac
453# holds no characters for are reported as orphans and **never written** --
454# projection does not delete a sheet it has no row for.
455#
456# Since 2026-10-08 this flag grants one more thing than its name says: it is
457# what mounts the media root, so it also gates **session preparation** -- the
458# DM uploading battlemaps and handouts into `table/` from the Almanac. Kept as
459# one flag rather than split because it is one grant: either this service can
460# write into the campaign tree or it cannot, and two flags would imply a
461# distinction the mount cannot enforce. Turning it off closes both, and both
462# say so rather than failing quietly.
463dnd_almanac_projection: true
464
465# Polled after starting the table server, before a session is reported live.
466# Chromium and x264 coming up from cold is a few seconds; generous so a slow
467# first start does not read as a failure.
468chamberlain_table_ready_timeout: 90
469
470# Post-processing can run the better part of an hour on a long session, and
471# the chronicle alone -- summaries, claims, thread map, weave -- now takes
472# roughly an hour and a half. This is a backstop: the per-call LLM timeout
473# (900s) fails an individual stuck call long before this bites.
474chamberlain_post_timeout: 14400
475
476# The mini forge on the table Pi. Empty string disables the feature and the
477# cast cards hide the "Load mini" button.
478chamberlain_forge_url: "http://dnd-table.home:{{ dnd_forge_port | default(8097) }}"
479
480# A four-colour e-paper refresh takes 15-25s and the forge answers only when
481# the panel has actually finished, so this covers the whole write. The forge's
482# own REFRESH_TIMEOUT is 45s, so a wedged panel is reported by the forge with a
483# useful message rather than timing out blindly here.
484chamberlain_forge_timeout: 90
485
486# Recap and invitation email. Left unset by default: with no SMTP host the
487# Chamberlain hides the mail buttons and says what is missing, rather than
488# offering an action that is certain to fail.
489chamberlain_smtp_host: "{{ (vault_runner | default({})).dnd_smtp_host | default('') }}"
490chamberlain_smtp_port: 587
491chamberlain_smtp_username: "{{ (vault_runner | default({})).dnd_smtp_username | default('') }}"
492chamberlain_smtp_password: "{{ (vault_runner | default({})).dnd_smtp_password | default('') }}"
493chamberlain_mail_from: "{{ (vault_runner | default({})).dnd_mail_from | default('') }}"
494chamberlain_mail_to: "{{ (vault_runner | default({})).dnd_mail_to | default('') }}"
495
496# ------------------------------------------------------------------------------
497# CAMERA RECEIVER - receives, records and previews the table camera
498# ------------------------------------------------------------------------------
499# Per session, like the agent: the Pi sends RTP only while the projector is awake,
500# and there is nothing to record between sessions. Started by the Chamberlain.
501dnd_camera_enabled: "{{ dnd_table_enabled }}"
502dnd_camera_image: >-
503 {{ dnd_registry }}/{{ dnd_registry_project }}/dnd-table-camera:{{ dnd_image_tag }}
504
505# Must match dnd_table_camera_rtp_port in the dnd-table role -- this is the
506# receiving end of that stream.
507dnd_camera_rtp_port: 5602
508dnd_camera_http_port: 8096
509
510# Deeper than the display path's 30ms jitter buffer: nothing here is
511# latency-critical, and depth costs only memory while making a lost packet
512# survivable.
513dnd_camera_jitter_ms: 200
514
515# splitmuxsink writes the MP4 index only when it closes a segment, so the open
516# segment is unplayable. Two minutes caps what a hard kill can cost.
517dnd_camera_segment_minutes: 2
518
519# The preview the DM page embeds. Small and frame-dropped on purpose: it answers
520# "is the table being captured", and full-rate 720p JPEG would cost more
521# bandwidth than the H.264 original for a worse picture.
522dnd_camera_preview_fps: 5
523dnd_camera_preview_width: 640
524dnd_camera_preview_height: 360
525dnd_camera_preview_quality: 65
526
527# The tracking tap: a second decode nothing consumes yet. Off until there is a
528# detector to read it.
529dnd_camera_frames_enabled: true
530
531# --- mini tracking ------------------------------------------------------------
532# AprilTags on the 3D-printed minis, detected from the frame tap above and
533# published to dnd/reveal/set as tokens. The tower number printed on a mini IS
534# its tag id, so the mini forge's registry is what turns a detection into a
535# name -- there is no second mapping to keep in sync.
536#
537# Enabling this REQUIRES dnd_camera_frames_enabled: the tracker reads that tap,
538# and without it the loop runs and never sees anything. The service says so at
539# startup rather than looking broken.
540dnd_camera_track_minis: true
541
542# --- how the tag is read ------------------------------------------------------
543# The family the minis are actually printed in. **OpenCV's ArUco cannot read
544# `tagCircle21h7`** -- it carries only 16h5, 25h9, 36h10 and 36h11 -- so the
545# tracker needs the `pupil_apriltags` backend. Measured 2026-08-29: a sweep of
546# all eight OpenCV dictionaries against a real table frame found nothing, while
547# the same frame decoded under pupil_apriltags at hamming 0, margin 76.
548#
549# `auto` prefers pupil_apriltags and refuses BY NAME if the family is one ArUco
550# does not have, rather than silently detecting nothing.
551dnd_camera_detector_backend: "auto"
552dnd_camera_tag_family: "tagCircle21h7"
553
554# Upscale applied before detection. **Do not lower this.** Measured in the
555# deployed rig with the projector running, one 15 mm tag at 1080p:
556#
557# projected image x1 x3
558# SMPTE colour bars hamming 0 hamming 1 (worse)
559# BUSY battlemap-like NO DETECTION hamming 0, margin ~29
560#
561# Flat test patterns make x1 look better; a real map makes it fail completely.
562# The Pi's hardware H.264 encoder caps the stream at 1080p, so upscaling is the
563# only way to give the detector more pixels on this path.
564dnd_camera_stream_upscale: 3
565
566# Detect only where the projector paints, derived from the homography rather
567# than configured: the camera sees far more table than the projection covers,
568# and the one measured false positive in this project came from a QR code on a
569# box well outside the map. The margin covers a mini just off the map, the
570# 3.41 mm parallax on a 50 mm tower, and calibration drift.
571dnd_camera_crop_to_surface: true
572dnd_camera_crop_margin: 0.08
573
574# The two overlay layers, each switchable on its own. Fog is the cleared area
575# around each mini; spot+banner is the white mark and the curved name. They are
576# separate because they fail separately -- fog being unwanted is a different
577# problem from a detector misreading the table.
578dnd_camera_fog_of_war: true
579dnd_camera_spot_banner: true
580
581# Measured: 0.5 ms JPEG decode + 1.5 ms detect = 2.0 ms per frame at 1280x720
582# with six tags, so 3 Hz costs about 0.6% of one core. The rate is chosen for
583# how fresh the overlay feels, not for CPU -- a placed mini settles in ~0.7 s
584# with the tracker's smoothing.
585dnd_camera_track_hz: 3
586
587# Normalised to the projected surface, so they survive a resolution change.
588#
589# **The projected surface is 990 x 580 mm, measured with a tape on the table.**
590#
591# Do not re-derive this from the homography and a mm/px figure. That is how the
592# previous two attempts went wrong: the old comment assumed 1.2 m, a later one
593# computed 1343 x 850 mm from the docs' 0.716 mm/px, and both were inflated --
594# the real scale is 0.544 mm/px, because the camera sits ~970 mm above the
595# table rather than the 1009 mm the docs record. A tape measure settles in one
596# minute what pixel arithmetic got wrong twice.
597#
598# Judged on the table, projected, against a real mini -- not computed. Three
599# sizes were tried and photographed; these are the ones that read.
600#
601# **Radii are fractions of the canvas's SHORTER side, not its width.** The
602# kiosk uses `unit = Math.min(w, h)` so a circle stays circular on a
603# non-square surface, so one unit is the 1080 px height -- which is the
604# projection's 580 mm depth, not its 990 mm width. radius_mm = value * 580.
605#
606# Getting this wrong overstates every radius by 1.7x, and it is what sent the
607# spot the wrong way on 2026-08-30: reducing 0.050 -> 0.015 to move the rim off
608# the tag actually moved it onto the tag (8.7 mm, against the ~11.1 mm the tag
609# needs), because the arithmetic behind the change used 990.
610#
611# fog 0.130 -> 129 mm radius (257 mm across)
612# At the old 0.062 the figure nearly filled its own light
613# and almost no terrain was revealed.
614# spot 0.030 -> 17 mm radius (35 mm across, about a 25 mm mini base)
615# The old 0.011 was an 11 mm radius -- the mini hid its own
616# marker entirely.
617# banner 0.045 -> 26 mm radius
618# Outside the spot so the name is not written across the
619# figure, inside the fog so it stays lit.
620#
621# **Brought in from 0.075 on 2026-08-30.** That distance was
622# partly a clearance allowance -- keeping the name away from
623# a figure that might stand in front of it. The banner is now
624# aimed at the projector's optical axis (see kiosk.html's
625# drawMarks), so it always lands on the NEAR side of the mini
626# where the figure cannot occlude it, and the allowance is no
627# longer needed. 26 mm clears the 17 mm spot rim by 9 mm.
628#
629# **The spot's apparent size was never mostly about this number.** At 0.050 it
630# is already four times the width of the base and still looked tiny, because
631# its gradient faded to 55% alpha by a third of the radius and to nothing by
632# the rim -- two thirds of the disc was barely painted. Fixed in kiosk.html by
633# making the core solid; enlarging the radius alone only spreads the haze.
634#
635# **Set for detection, not looks.** The tracker publishes the TAG's position,
636# so the spot is centred on the tag sitting on top of the tower, and what
637# matters is where the disc's EDGE lands. A rim crossing the tag destroys the
638# local contrast the decoder needs and detection stops dead -- measured 0/31
639# frames against 12/12 with the spot off, identical with fog off, oscillating
640# on a ~4 s cycle that read as the projector flickering. See kiosk.html's
641# drawMarks for the measurements and for why it is NOT over-exposure.
642#
643# The tag needs about 11 mm of clearance (7.5 mm half-width + 3.6 mm
644# parallax), so the rim has to sit outside that:
645#
646# 0.015 -> 8.7 mm rim ON the tag <- tried, still flickered
647# 0.030 -> 17.4 mm rim clear of it
648#
649# Paired with 0.50 alpha and a long feather in kiosk.html: the alpha softens
650# the rim wherever it lands, the radius keeps it off the tag, and
651# CAMERA_TRACK_HOLD_SECONDS rides out whatever still gets missed. Three
652# independent guards, because each alone was measured insufficient.
653dnd_camera_fog_radius: 0.130
654dnd_camera_spot_radius: 0.030
655dnd_camera_banner_radius: 0.045
656
657# How long a tag keeps its last position after it stops being seen.
658#
659# **20 s, raised from the code default of 10 on 2026-09-01.** The reasoning is
660# about what a miss actually costs: a mini does not leave the table mid-scene,
661# so holding a position that is still true is nearly free, while dropping one
662# that is still true reads as the system breaking.
663#
664# Measured the same evening on a busy blue-green battlemap, 6 minis, spot on:
665# tracked oscillated 4..6 with 17 changes in 60 s, and sat at exactly 5/6 for
666# 40 s straight -- one tag decoding at a margin of 17-22 against the 36-42 the
667# healthy ones managed. The tags span only ~25-30 camera pixels and the video
668# link is soft, so the marginal ones drop on any small disturbance.
669#
670# **A longer hold does not fix that** -- it rides it out. The real levers are
671# physically larger tags, a cleaner video link, and keeping the name banner off
672# the tag. This buys quiet at the table while those are addressed.
673#
674# The cost: a mini genuinely taken off the board stays lit for 20 s. Acceptable
675# at a table where the answer to "did it drop?" is almost always "no, and it is
676# still where it was".
677dnd_camera_track_hold_seconds: 20.0
678
679# The opening sequence's music, relative to dnd_media_dir. Seeded by hand into
680# `opening/` rather than shipped: it is a licensed track, not project content.
681dnd_opening_track: "opening/already-given-up.mp3"
682
683dnd_chronicle_model: "qwen3:14b"
684
685# --- which chronicler writes the chapter --------------------------------------
686# `local` keeps everything in the house: ~66 calls against qwen3:14b, no network,
687# no third party. `remote` sends the whole transcript to a provider in one call.
688#
689# Default local, deliberately. Switching to remote sends four hours of the
690# table's conversation to somebody else's servers, which is a decision to make
691# on purpose rather than inherit from a default.
692#
693# Measured across eight real sessions: remote cut dice and table leakage from 83
694# references to 12 and ran in ~5 minutes against ~2 hours, at roughly $0.05 a
695# session.
696dnd_chronicle_backend: "local"
697
698# Only read when the backend is `remote`. The key lives in the vault as
699# vault_runner.deepseek_api_key; without it the post container starts and the
700# first chapter call fails with an auth error, which is the honest failure.
701dnd_remote_base_url: "https://api.deepseek.com"
702dnd_remote_model: "deepseek-v4-pro"
703
704# Characters of transcript one call may carry. ~66,650 chars per hour of
705# session, so this is roughly a thirty-hour evening -- a guard against a
706# runaway file rather than a model limit.
707dnd_transcript_budget: 2000000
708dnd_model_dir: "{{ dnd_config_dir }}/models"
709# cuda when the driver matches torch's build; cpu still runs above realtime.
710dnd_diarize_device: "cuda"
711
712# Speech to text (Speaches / faster-whisper). Generic and OpenAI-compatible,
713# so anything else on the runner can use it too.
714dnd_stt_model_dir: "{{ dnd_config_dir }}/stt-models"
715dnd_stt_host_port: 9010
716
717# distil-large-v3 is near large-v3 accuracy for English at a fraction of the
718# VRAM, which matters because STT shares the 12 GB card with the cue-selection
719# model during a live session.
720dnd_stt_model: "Systran/faster-distil-whisper-large-v3"
721dnd_stt_compute_type: "int8_float16"
722
723# -1 keeps the model resident forever. Sessions have long silent gaps and a
724# cold reload would add seconds to the live path.
725dnd_stt_model_ttl: -1
726dnd_stt_start_period: "300s"
727dnd_stt_model_download_timeout: 900
728dnd_stt_log_level: "info"
729
730# The model the *post* chain transcribes with, which is deliberately not the
731# live one: `dnd-transcribe` reads the recordings whole with no deadline, so it
732# buys accuracy where the live path has to buy latency. The difference is not
733# academic -- measured, the distilled model turns "Fjord" into "Ford" and drops
734# a named NPC entirely, and a substitution is worse than an omission because
735# nothing downstream can doubt it.
736#
737# It goes through the same Speaches service, so it lands in the same cache; it
738# simply has to be asked for separately.
739dnd_post_stt_model: "Systran/faster-whisper-large-v3"
740
741# The artwork model. Pulled at deploy time for the same reason the Whisper
742# models are: measured, a cold cache cost a session's artwork step 54 minutes
743# against a ~6 minute render, because the download happens inside the step and
744# reports itself as "generating".
745#
746# Fetched with huggingface-cli inside the post image rather than by URL: the
747# repository is ~13 GB across many files, and the cache layout the loader
748# expects (blobs, snapshots, refs) is not something to reproduce by hand.
749dnd_artwork_model: "black-forest-labs/FLUX.2-klein-4B"
750dnd_artwork_model_download_timeout: 3600
751
752# --- D&D table server -------------------------------------------------------
753# Renders the kiosk page with headless Chromium and streams it to the table Pi,
754# which only hardware-decodes it. This is why the Pi can be a thin appliance:
755# all the rendering happens here, where there is a GPU and no thermal limit.
756#
757# Unlike dnd-agent, this image is pulled from Harbor rather than built here --
758# it is also deployed to the Pi, so one build serves both.
759dnd_table_enabled: false
760# Registry settings and the image tag are shared with the agent above, so one
761# tag reverts every component together.
762dnd_table_runner_image: >-
763 {{ dnd_registry }}/{{ dnd_registry_project }}/dnd-tabletop-server:{{ dnd_image_tag }}
764
765# Where the kiosk stream is sent. Must match the Pi's TABLE_RTP_PORT.
766dnd_table_pi_host: "dnd-table.home"
767dnd_table_rtp_port: 5600
768
769# **1080p, to match the Pi's forced HDMI mode.**
770#
771# This was 1280x720 "to match the projector's native panel", which was true of
772# the XGODY Gimbal 3 and is not true of the 2026 Magcubic HY320 Pro -- that one
773# advertises 1920x1080 and the Pi's cmdline forces exactly that
774# (`video=HDMI-A-1:1920x1080@60D`). Sending 720p into a 1080p pipeline made the
775# projector rescale it, and it did not rescale it to the same rectangle a
776# native 1080p signal fills.
777#
778# **Measured 2026-08-30**, kiosk at 720p, probing five known token positions
779# and comparing against the calibrated surface:
780#
781# x=0.25 err +44 px y=0.25 err +89 px
782# x=0.50 err -37 px y=0.50 err +1 px
783# x=0.75 err -121 px y=0.75 err -78 px
784#
785# Errors that change sign across the surface and grow toward the edges are a
786# SCALE error, not an offset. Fitting them: the rendered content occupied
787# **81.8% x 71.9%** of the surface the homography was solved against -- so a
788# mini's overlay sat visibly inboard of the mini, worst at the edges.
789#
790# The calibration board is projected through this same path, so matching the
791# two resolutions is what makes a solved homography mean anything.
792dnd_table_width: 1920
793dnd_table_height: 1080
794dnd_table_fps: 30
795# **Raised with the resolution, for exactly the reason the camera stream was.**
796#
797# 4000 kbps was 0.145 bits/px at 1280x720 and is only **0.064** at 1920x1080 --
798# well under the 0.10-0.15 band H.264 needs to stay clean, and the same mistake
799# this file already documents for `dnd_table_camera_bitrate`. Raising the
800# kiosk to 1080p without raising its bitrate starved it.
801#
802# **Then measured again, and lowered — because on WiFi the PACKET rate binds
803# long before the bit rate does.**
804#
805# `x264enc tune=zerolatency bitrate=N` targets a constant N. It does **not**
806# fall away on static content the way a quality-targeted encode would; it
807# spends the budget. Measured 2026-08-30 with the kiosk showing a **completely
808# static** canvas (zero pixels changed between frames 0.4 s apart):
809#
810# 8000 kbps -> 4.87 Mbit/s, 1914 packets/s, flat to within 48 bytes/s
811#
812# 1914 pps is at the practical ceiling for 2.4 GHz 802.11n, where every packet
813# needs its own airtime slot and ACK regardless of size. Packets then arrive
814# late, `rtpjitterbuffer drop-on-latency=true` discards them, and the decoder
815# smears its last good row down the screen -- reported as
816# `v4l2h264dec: Driver bug detected`. **The jitter is a symptom of the packet
817# rate, not an independent fault**, which is why deepening the buffer to 200 ms
818# reduced it without fixing it.
819#
820# **The 8000 figure was a mistake made from a correct principle.** Pitfall #34
821# is about a *starved* encoder and it is real -- but it reasons in bits per
822# pixel, which says nothing about packets per second, and the note here that
823# 8000 was "trivial on the wired link" was simply false: the Pi is on WiFi.
824#
825# It also credited the raise with fixing calibration. It did not. The
826# chessboard failed for want of a white quiet zone (see BOARD_QUIET_SQUARES in
827# dnd_table_camera), and the bitrate change was measured at the time to leave
828# the projected board exactly as it was.
829#
830# 3000 at 1080p30 is ~0.048 bits/px, below the 0.10-0.15 band -- acceptable
831# here because the content is flat colour, hard-edged shapes and a grid, not
832# camera noise. **If the calibration board stops solving, raise this first.**
833#
834# ============================================================================
835# **THIS VALUE IS A WIFI WORKAROUND AND IS TEMPORARY.**
836#
837# The table Pi is on WiFi only while it is bench-tested (`dnd_table_link` in
838# roles/dnd-table/defaults). 3000 is low because a padded stream congested a
839# 2.4 GHz link -- NOT because 3000 is the right bitrate for this content. On
840# wire the congestion does not exist and this is simply starved.
841#
842# **When the Pi is wired: set `dnd_table_link: wired` and raise this back
843# toward 8000**, then confirm the calibration board still solves and the
844# projected image is clean. Verified 2026-08-30 that 3000 still calibrates
845# (HTTP 200), so the floor is somewhere below it -- but a value chosen to
846# survive a congested link should not outlive the link.
847# ============================================================================
848dnd_table_bitrate_kbps: 3000
849
850# The DM control surface.
851dnd_table_http_port: 8081
852
853# Which networks reach the DM screen without a login.
854#
855# **The DM exemption, as a deliberate decision.** That screen exists only while
856# everyone is in the room, and a session expiring mid-combat is a worse failure
857# than the one a login would prevent. A remote player authenticates instead:
858# they sign into the Almanac, and because the proxy serves both under one
859# hostname the session cookie reaches the table server, which asks the Almanac
860# who they are.
861#
862# The house networks, not 0.0.0.0/0: anything arriving from outside has to
863# identify itself. Setting this to "" turns the exemption off entirely, which
864# is what a deployment reachable from the internet would want -- the DM would
865# then sign in like everybody else.
866dnd_table_trusted_networks: "127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
867
868# Maps and handouts. Lives on the NAS beside the rest of the campaign media.
869dnd_table_media_dir: "{{ dnd_media_dir }}/table"
870
871# Creature tokens, the DM's own uploads, and saved encounters.
872#
873# **A sibling of the table media directory, not a child of it, and that is
874# load-bearing.** The set is over a thousand files, and the media library is
875# scanned with an unfiltered `rglob("*")` -- a token tree inside it would bury
876# every map and handout the DM owns and make each listing walk them all.
877#
878# Not session-scoped, unlike the media root: a goblin is a goblin in every
879# campaign. Seeded once and never overwritten by a deploy, because `custom/`
880# and `encounters/` hold the DM's own work.
881#
882# **Two sets exist on the share and this chooses between them:**
883#
884# tokens-fa FA_Tokens_Webp -- 1,437 borderless top-down tokens, 165 MB.
885# The art all faces south, so a creature can be ROTATED to face
886# the nearest party member. Sizes come from the filenames.
887# tokens TooManyTokens -- 374 creatures with circular borders, 615 MB.
888# Cannot be rotated (spinning a border looks broken) and needs
889# the curated size table in `creature_sizes.py`.
890#
891# FA is the default because rotation is worth more than the extra art, and its
892# tokens read better on a projected map. The other is kept on the share, and
893# the code still supports it -- `rotatable` is per-token, so switching back is
894# only this variable.
895dnd_token_dir: "{{ dnd_media_dir }}/tokens-fa"
896
897# Frigate - AI NVR System
898frigate_enabled: true
899frigate_port: 5000
900frigate_rtmp_port: 1935
901frigate_rtsp_port: 8554
902frigate_go2rtc_port: 1984
903frigate_config_dir: "/docker/frigate"
904frigate_data_dir: "{{ runner_nfs_mount_dir }}/frigate"
905frigate_mqtt_enabled: true
906mqtt_host: "homeassistant.home"
907mqtt_port: 1883
908frigate_hardware_acceleration: "vaapi" # vaapi, nvdec, nvenc, qsv, or none
909
910# Frigate ONVIF default settings
911frigate_onvif_autotracking: false
912
913# Frigate cameras configuration (from vault)
914frigate_cameras:
915 - name: "dining-room"
916 host: "{{ vault_runner.dining_room_camera_host | default('') }}"
917 username: "{{ vault_runner.dining_room_camera_user | default('') }}"
918 password: "{{ vault_runner.dining_room_camera_pass | default('') }}"
919 path: "/stream1"
920 port: 554
921 onvif_port: 2020
922 enabled: true
923
924 - name: "living-room"
925 host: "{{ vault_runner.living_room_camera_host | default('') }}"
926 username: "{{ vault_runner.living_room_camera_user | default('') }}"
927 password: "{{ vault_runner.living_room_camera_pass | default('') }}"
928 path: "/stream1"
929 port: 554
930 onvif_port: 2020
931 enabled: true
932
933 - name: "bed-room"
934 host: "{{ vault_runner.bed_room_camera_host | default('') }}"
935 username: "{{ vault_runner.bed_room_camera_user | default('') }}"
936 password: "{{ vault_runner.bed_room_camera_pass | default('') }}"
937 path: "/stream1"
938 port: 554
939 onvif_port: 2020
940 enabled: true
941
942 - name: "alina-office"
943 host: "{{ vault_runner.alina_office_camera_host | default('') }}"
944 username: "{{ vault_runner.alina_office_camera_user | default('') }}"
945 password: "{{ vault_runner.alina_office_camera_pass | default('') }}"
946 path: "/stream1"
947 port: 554
948 onvif_port: 2020
949 enabled: true
950
951 - name: "street-cam"
952 host: "{{ vault_runner.street_cam_host | default('') }}"
953 username: "{{ vault_runner.street_cam_user | default('') }}"
954 password: "{{ vault_runner.street_cam_pass | default('') }}"
955 path: "/stream1"
956 port: 554
957 onvif_port: 2020
958 enabled: true
959
960 - name: "pi-cam"
961 host: "{{ vault_runner.pi_cam_host | default('') }}"
962 username: "{{ vault_runner.pi_cam_user | default('') }}"
963 password: "{{ vault_runner.pi_cam_pass | default('') }}"
964 path: "{{ vault_runner.pi_cam_path | default('/doorbell') }}"
965 port: 8554
966 enabled: true
967
968# Cameras with ONVIF support (for PTZ control)
969frigate_onvif_cameras:
970 - name: "dining-room"
971 host: "{{ vault_runner.dining_room_camera_host | default('') }}"
972 port: 2020
973 username: "{{ vault_runner.dining_room_camera_user | default('') }}"
974 password: "{{ vault_runner.dining_room_camera_pass | default('') }}"
975 - name: "living-room"
976 host: "{{ vault_runner.living_room_camera_host | default('') }}"
977 port: 2020
978 username: "{{ vault_runner.living_room_camera_user | default('') }}"
979 password: "{{ vault_runner.living_room_camera_pass | default('') }}"
980 - name: "bed-room"
981 host: "{{ vault_runner.bed_room_camera_host | default('') }}"
982 port: 2020
983 username: "{{ vault_runner.bed_room_camera_user | default('') }}"
984 password: "{{ vault_runner.bed_room_camera_pass | default('') }}"
985 - name: "alina-office"
986 host: "{{ vault_runner.alina_office_camera_host | default('') }}"
987 port: 2020
988 username: "{{ vault_runner.alina_office_camera_user | default('') }}"
989 password: "{{ vault_runner.alina_office_camera_pass | default('') }}"
990 - name: "street-cam"
991 host: "{{ vault_runner.street_cam_host | default('') }}"
992 port: 2020
993 username: "{{ vault_runner.street_cam_user | default('') }}"
994 password: "{{ vault_runner.street_cam_pass | default('') }}"
995
996# Cameras for snapshot capture
997frigate_snapshot_cameras:
998 - "living-room"
999 - "dining-room"
1000 - "bed-room"
1001 - "alina-office"
1002 - "street-cam"
1003 - "pi-cam"
1004
1005# Per-camera record override (cameras listed here have recording disabled)
1006frigate_cameras_record_disabled:
1007 - "bed-room"
1008
1009# Frigate profile system (home=record-only, away=full detection)
1010frigate_profiles:
1011 home:
1012 detect_enabled: false
1013 detectors_enabled: false
1014 cameras:
1015 - street-cam
1016 - pi-cam
1017 hwaccel_override_cameras:
1018 - street-cam
1019 - pi-cam
1020 default_profile: false
1021 away:
1022 detect_enabled: true
1023 detectors_enabled: true
1024 cameras:
1025 - living-room
1026 - dining-room
1027 - bed-room
1028 - alina-office
1029 - street-cam
1030 - pi-cam
1031 default_profile: true
1032
1033# Frigate manager service
1034frigate_manager_enabled: true
1035frigate_snapshot_interval_minutes: 30
1036frigate_profile_switch_timeout: 120
1037
1038# Immich - Photo Management
1039immich_enabled: true
1040immich_server_port: 2283
1041immich_ml_port: 3003
1042immich_config_dir: "/docker/immich"
1043immich_data_dir: "/docker/immich"
1044immich_upload_dir: "{{ runner_nfs_mount_dir }}/immich/library"
1045immich_db_name: "{{ vault_runner.postgres_db | default('') }}"
1046immich_db_user: "{{ vault_runner.postgres_user | default('') }}"
1047immich_redis_enabled: true
1048immich_ml_enabled: true
1049immich_facial_recognition: true
1050immich_hardware_acceleration: "none" # none, vaapi, nvdec, nvenc, or qsv
1051
1052# External library mounts (read-only photo libraries from NFS)
1053immich_external_libraries:
1054 - name: "yannick"
1055 host_path: "{{ runner_nfs_mount_dir }}/immich/yannick"
1056 container_path: "/yannick"
1057 - name: "alina"
1058 host_path: "{{ runner_nfs_mount_dir }}/immich/alina"
1059 container_path: "/alina"
1060
1061# Forgejo - Git Server
1062forgejo_enabled: true
1063forgejo_http_port: 3010
1064forgejo_ssh_port: 2222
1065forgejo_config_dir: "/docker/forgejo"
1066forgejo_data_dir: "{{ runner_nfs_mount_dir }}/forgejo"
1067forgejo_db_type: "sqlite3"
1068forgejo_app_name: "Forgejo Git Service"
1069forgejo_domain: "forgejo.home"
1070forgejo_ssh_domain: "{{ ansible_default_ipv4.address }}"
1071
1072# Forgejo Runner Configuration
1073forgejo_runner_enabled: true
1074
1075# Cross-architecture builds. The Pi images (display, ceiling wash) are arm64
1076# while the runner is x86_64; QEMU binfmt handlers let them build here. The
1077# runners mount the host's docker socket, so this is host state rather than
1078# anything in the runner image.
1079forgejo_runner_cross_build_enabled: true
1080forgejo_runner_cross_platforms: "arm64"
1081forgejo_runner_binfmt_image: "docker.io/tonistiigi/binfmt:qemu-v10.2.3"
1082forgejo_runner_token: "{{ vault_runner.forgejo_runner_token | default('changeme') }}"
1083forgejo_runner_name: "default-runner"
1084forgejo_runner_capacity: 2
1085forgejo_runner_loglevel: info
1086forgejo_runner_base_image: ubuntu:22.04
1087forgejo_runner_user: runner
1088forgejo_runner_uid: 1000
1089forgejo_runner_version: 9.1.1
1090
1091# Forgejo Ansible Runner (for running Ansible playbooks in CI)
1092forgejo_ansible_runner_enabled: true
1093forgejo_ansible_runner_token: "{{ vault_runner.forgejo_ansible_runner_token | default('changeme') }}"
1094forgejo_ansible_runner_name: "ansible-runner"
1095
1096# A runner that executes each job in a container of the job's choosing, rather
1097# than inside the runner itself. Kept separate from the host runner so adding
1098# it cannot disturb workflows that already build successfully there.
1099#
1100# Labels are `name:docker://image`. `docker` is a general Ubuntu image with the
1101# usual build tooling; add more entries for toolchains worth having ready.
1102forgejo_docker_runner_enabled: true
1103# No fallback to another runner's token: registration tokens are single-use,
1104# so reusing one fails inside the container at start rather than at deploy
1105# time, which is a confusing place to find out. Generate a token under
1106# Site Administration -> Actions -> Runners and put it in the vault.
1107forgejo_docker_runner_token: "{{ vault_runner.forgejo_docker_runner_token | default('') }}"
1108forgejo_docker_runner_name: "docker-runner"
1109forgejo_docker_runner_labels: "docker:docker://catthehacker/ubuntu:act-22.04"
1110forgejo_ansible_runner_capacity: 1
1111forgejo_ansible_runner_version: 9.1.1
1112
1113# Web Tools - PDF Processing, File Conversion, Data Analysis, Developer Utilities
1114web_tools_enabled: true
1115web_tools_config_dir: "/docker/web-tools"
1116web_tools_data_dir: "/docker/web-tools"
1117
1118# Stirling-PDF
1119web_tools_stirling_port: 8090
1120web_tools_stirling_max_file_size: 100
1121
1122# ConvertX
1123web_tools_convertx_port: 8091
1124web_tools_convertx_jwt_secret: "{{ vault_runner.web_tools_convertx_jwt_secret | default('changeme-generate-a-real-secret') }}"
1125web_tools_convertx_auto_delete_hours: 24
1126
1127# CyberChef
1128web_tools_cyberchef_port: 8092
1129
1130# IT-Tools
1131web_tools_it_tools_port: 8093
1132
1133# Tandoor - Recipe Manager
1134tandoor_enabled: true
1135tandoor_port: 8010
1136tandoor_config_dir: "/docker/tandoor"
1137tandoor_data_dir: "/docker/tandoor"
1138tandoor_media_dir: "{{ tandoor_data_dir }}/media"
1139tandoor_static_dir: "{{ tandoor_data_dir }}/static"
1140tandoor_db_engine: "django.db.backends.postgresql"
1141
1142# Ghost CMS - Headless CMS
1143ghost_enabled: true
1144ghost_port: 2368
1145ghost_config_dir: "/docker/ghost"
1146ghost_data_dir: "/docker/ghost"
1147ghost_content_dir: "{{ ghost_data_dir }}/content"
1148ghost_db_client: "mysql"
1149ghost_db_host: "ghost-mysql"
1150ghost_db_name: "ghost"
1151ghost_db_user: "ghost"
1152ghost_url: "http://ghost.home"
1153
1154# ==============================================================================
1155# LIVEKIT - WebRTC Conferencing (SFU) + Meet frontend
1156# ==============================================================================
1157# LiveKit is a WebRTC SFU. Unlike the other runner services it runs on
1158# network_mode: host because Docker's userland proxy rewrites UDP source
1159# addresses, which breaks ICE candidate resolution and silently kills media.
1160livekit_enabled: true
1161livekit_config_dir: "/docker/livekit"
1162livekit_data_dir: "{{ livekit_config_dir }}/data"
1163
1164# Signaling / API (HTTP + WebSocket)
1165livekit_port: 7880
1166# WebRTC over TCP (ICE/TCP fallback for restrictive networks)
1167livekit_tcp_port: 7881
1168# WebRTC over UDP - single-port mux, avoids the 50000-60000 range entirely
1169livekit_udp_port: 7882
1170# Meet frontend (bridge network, normal published port)
1171livekit_meet_port: 7883
1172
1173# Public hostnames handed to browser clients. When fronted by a reverse proxy
1174# these are the proxy's names; otherwise they resolve straight to this host.
1175livekit_domain: "livekit.home"
1176livekit_sfu_domain: "{{ livekit_domain }}"
1177
1178# Set true when a reverse proxy terminates TLS in front of both hostnames.
1179# Browsers require a secure context for getUserMedia, and a page served over
1180# HTTPS may not open a ws:// socket, so this must be on for any non-localhost
1181# browser access.
1182livekit_tls_enabled: false
1183
1184# Behind a proxy the public URLs carry no port (443 is implied). Direct access
1185# needs the explicit container ports.
1186livekit_public_url: >-
1187 {{ 'https://' ~ livekit_domain
1188 if livekit_tls_enabled
1189 else 'http://' ~ livekit_domain ~ ':' ~ livekit_meet_port }}
1190livekit_public_ws_url: >-
1191 {{ 'wss://' ~ livekit_sfu_domain
1192 if livekit_tls_enabled
1193 else 'ws://' ~ livekit_sfu_domain ~ ':' ~ livekit_port }}
1194
1195# API credentials (from vault). Generate with:
1196# docker run --rm livekit/livekit-server generate-keys
1197livekit_api_key: "{{ vault_runner.livekit_api_key | default('') }}"
1198livekit_api_secret: "{{ vault_runner.livekit_api_secret | default('') }}"
1199
1200livekit_log_level: "info"
1201
1202# Avatar for the session agent, served by Meet at the site root so it can be
1203# shown in place of a video tile (the agent publishes no camera).
1204livekit_agent_avatar_enabled: true
1205livekit_agent_avatar_filename: "thaumaturgeon.png"
1206livekit_agent_avatar_src: "files/thaumaturgeon.png"
1207
1208# Room behaviour
1209livekit_room_auto_create: true
1210livekit_room_empty_timeout: 300 # seconds before an empty room closes
1211livekit_room_departure_timeout: 20
1212
1213# Meet frontend build. Upstream publishes no release tags, so this is pinned to
1214# a specific commit SHA rather than tracking the main branch. Bump deliberately.
1215livekit_meet_repo_url: "https://github.com/livekit-examples/meet.git"
1216livekit_meet_repo_version: "665e1cb7841ab872de0d8e5c310744009a763b08"
1217# Matches the packageManager field in Meet's package.json
1218livekit_meet_pnpm_version: "10.18.2"
1219livekit_meet_node_version: "20-alpine"
1220
1221# Resource limits
1222livekit_memory_limit: "2g"
1223livekit_cpu_limit: "2"
1224livekit_meet_memory_limit: "1g"
1225livekit_meet_cpu_limit: "1"
1226
1227cvat_config_dir: "/docker/cvat"
1228# CVAT - Data labeling
1229cvat_enabled: true
1230
1231cvat_repo_url: "https://github.com/cvat-ai/cvat.git"
1232cvat_repo_version: "{{ cvat_image_tag }}"
1233
1234cvat_admin_username: admin
1235cvat_admin_password: "{{ vault_runner.cvat_admin_password | default('change-me') }}"
1236cvat_admin_email: "{{ vault_runner.cvat_admin_email | default('change-me') }}"
1237
1238# Networking / access
1239cvat_domain: "cvat.home" # used by Traefik routing in CVAT compose
1240cvat_http_port: 8990 # Traefik "web" entrypoint in the default compose
1241cvat_https_enabled: false # add CVAT's https overlay when true
1242
1243cvat_share_dir: >-
1244 {{ (runner_nfs_mounts
1245 | selectattr('name','equalto','cvat')
1246 | map(attribute='local_path')
1247 | first)
1248 | default(runner_nfs_mount_dir ~ '/cvat', true) }}
1249
1250# Versioning / images
1251cvat_image_tag: "v2.44.3" # pulled via CVAT_VERSION; align with the git tag you run
1252
1253# Optional: serverless auto-annotation overlay (Nuclio/SAM/YOLO assist in CVAT)
1254cvat_serverless_enabled: false
1255
1256# Optional: use an external Postgres instead of the bundled one
1257cvat_external_db_enabled: false
1258cvat_db_host: "postgres.internal"
1259cvat_db_port: 5432
1260cvat_db_name: "cvat"
1261cvat_db_user: "cvat_user"
1262cvat_db_password: "{{ vault_runner.cvat_db_password | default('change-me') }}"
1263
1264# Optional: expose Traefik dashboard (binds host port below)
1265cvat_traefik_dashboard_enabled: false
1266cvat_dashboard_port: 8899
1267
1268# Optional: GPU reservation for CVAT server container (you must have host GPU runtime ready)
1269cvat_gpu_enabled: false
1270cvat_gpu_driver: "nvidia"
1271cvat_gpu_count: "all" # or a number like "1"
1272
1273# ==============================================================================
1274# DATABASE CONFIGURATIONS
1275# ==============================================================================
1276
1277# PostgreSQL (Immich)
1278postgres_enabled: "{{ immich_enabled }}"
1279postgres_config_dir: "/docker/immich/postgres"
1280postgres_db: "{{ vault_runner.postgres_db | default('') }}"
1281postgres_user: "{{ vault_runner.postgres_user | default('') }}"
1282postgres_version: "14"
1283
1284# Redis (Immich)
1285redis_enabled: "{{ immich_redis_enabled }}"
1286redis_config_dir: "/docker/immich/redis"
1287redis_port: 6379
1288
1289# MySQL (Ghost CMS)
1290mysql_enabled: "{{ ghost_enabled }}"
1291mysql_config_dir: "/docker/ghost/mysql"
1292mysql_db: "{{ ghost_db_name }}"
1293mysql_user: "{{ ghost_db_user }}"
1294mysql_version: "8.0"
1295
1296# ==============================================================================
1297# SECURITY SETTINGS (FROM VAULT)
1298# ==============================================================================
1299
1300# Database passwords
1301postgres_password: "{{ vault_runner.postgres_password | default('') }}"
1302mysql_password: "{{ vault_runner.mysql_password | default('') }}"
1303mysql_root_password: "{{ vault_runner.mysql_root_password | default('') }}"
1304
1305# Service secrets
1306immich_jwt_secret: "{{ vault_runner.immich_jwt_secret | default('') }}"
1307ghost_database_password: "{{ vault_runner.ghost_database_password | default('') }}"
1308tandoor_secret_key: "{{ vault_runner.tandoor_secret_key | default('') }}"
1309
1310# MQTT credentials (from vault - top-level, shared across all roles)
1311mqtt_username: ""
1312mqtt_password: ""
1313
1314# ==============================================================================
1315# DIRECTORY STRUCTURE
1316# ==============================================================================
1317
1318# Local configuration directories
1319runner_config_directories:
1320 - "{{ llm_stack_config_dir }}"
1321 - "{{ llm_stack_ollama_data_dir }}"
1322 - "{{ llm_stack_openwebui_data_dir }}"
1323 - "{{ llm_stack_litellm_data_dir }}"
1324 - "{{ frigate_config_dir }}"
1325 - "{{ immich_config_dir }}"
1326 - "{{ immich_config_dir }}/postgres"
1327 - "{{ immich_config_dir }}/redis"
1328 - "{{ immich_config_dir }}/library"
1329 - "{{ immich_config_dir }}/cache"
1330 - "{{ immich_config_dir }}/model-cache"
1331 - "{{ immich_config_dir }}/postgres-init"
1332 - "{{ forgejo_config_dir }}"
1333 - "{{ forgejo_config_dir }}/forgejo-runner-data"
1334 - "{{ forgejo_config_dir }}/forgejo-ansible-runner-data"
1335 - "{{ web_tools_config_dir }}"
1336 - "{{ tandoor_config_dir }}"
1337 - "{{ tandoor_data_dir }}/db"
1338 - "{{ tandoor_data_dir }}/media"
1339 - "{{ tandoor_data_dir }}/static"
1340 - "{{ ghost_config_dir }}/config"
1341 - "{{ ghost_config_dir }}/content"
1342 - "{{ ghost_config_dir }}/mysql"
1343
1344# NFS mount directories
1345runner_nfs_directories:
1346 - "{{ runner_nfs_mount_dir }}"
1347 - "{{ runner_nfs_mount_dir }}/frigate"
1348 - "{{ runner_nfs_mount_dir }}/immich"
1349 - "{{ runner_nfs_mount_dir }}/forgejo"
1350 - "{{ runner_nfs_mount_dir }}/harbor"
1351 - "{{ runner_nfs_mount_dir }}/cvat"
1352
1353# ==============================================================================
1354# PERFORMANCE SETTINGS
1355# ==============================================================================
1356
1357# Network performance tuning for NFS
1358runner_performance_tuning_enabled: true
1359runner_sysctl_settings:
1360 # Extreme performance network buffers for Ryzen 7 + 32GB RAM
1361 net.core.rmem_max: 268435456 # 256MB socket receive buffer
1362 net.core.wmem_max: 268435456 # 256MB socket send buffer
1363 net.core.rmem_default: 33554432 # 32MB default receive buffer
1364 net.core.wmem_default: 33554432 # 32MB default send buffer
1365 net.ipv4.tcp_rmem: "4096 131072 268435456" # TCP receive: 4KB min, 128KB default, 256MB max
1366 net.ipv4.tcp_wmem: "4096 131072 268435456" # TCP send: 4KB min, 128KB default, 256MB max
1367 net.core.netdev_max_backlog: 30000 # Handle high connection burst (32 connections)
1368 net.ipv4.tcp_congestion_control: "bbr" # BBR congestion control
1369 net.ipv4.tcp_window_scaling: 1 # Enable TCP window scaling
1370 net.ipv4.tcp_timestamps: 1 # Enable TCP timestamps for RTT calculation
1371 net.ipv4.tcp_sack: 1 # Enable selective acknowledgments
1372 # NFS client cache tuning for 32GB RAM
1373 vm.dirty_background_ratio: 3 # Start writeback at 3% (more aggressive)
1374 vm.dirty_ratio: 8 # Force writeback at 8% (more aggressive)
1375 vm.vfs_cache_pressure: 25 # Keep even more file cache (25% vs 50%)
1376 vm.min_free_kbytes: 131072 # Keep 128MB free for network buffers
1377
1378# Harbor Configuration (external deployment)
1379harbor_enabled: true
1380harbor_config_dir: "/docker/harbor"
1381harbor_version: "2.13.2"
1382harbor_hostname: "{{ vault_runner.harbor_hostname | default('registry.local') }}"
1383harbor_http_port: 8080
1384harbor_registry_port: 5000
1385harbor_admin_password: "{{ vault_runner.harbor_admin_password | default('changeme') }}"
1386harbor_db_password: "{{ vault_runner.harbor_db_password | default('changeme') }}"
1387harbor_data_volume: "{{ runner_nfs_mount_dir }}/harbor"
1388
1389# Docker resource limits
1390default_memory_limit: "1g"
1391default_cpu_limit: "1"
1392
1393# Health check configuration
1394health_check_interval: "30s"
1395health_check_timeout: "30s"
1396health_check_retries: 5
1397health_check_start_period: "60s"
1398
1399# ==============================================================================
1400# SERVICE HEALTH ENDPOINTS
1401# ==============================================================================
1402service_endpoints:
1403 ollama: "http://localhost:{{ llm_stack_ollama_port }}/api/tags"
1404 openwebui: "http://localhost:{{ llm_stack_openwebui_port }}/api/health"
1405 litellm: "http://localhost:{{ llm_stack_litellm_port }}/"
1406 frigate: "http://localhost:{{ frigate_port }}/api/config"
1407 immich: "http://localhost:{{ immich_server_port }}/api/server-info/ping"
1408 forgejo: "http://localhost:{{ forgejo_http_port }}/api/v1/version"
1409 stirling_pdf: "http://localhost:{{ web_tools_stirling_port }}/api/v1/info/status"
1410 convertx: "http://localhost:{{ web_tools_convertx_port }}/"
1411 cyberchef: "http://localhost:{{ web_tools_cyberchef_port }}/"
1412 it_tools: "http://localhost:{{ web_tools_it_tools_port }}/"
1413 tandoor: "http://localhost:{{ tandoor_port }}/accounts/login/"
1414 ghost: "http://localhost:{{ ghost_port }}/ghost/api/admin/site/"
1415 livekit: "http://localhost:{{ livekit_port }}/"
1416 livekit_meet: "http://localhost:{{ livekit_meet_port }}/"
1417
1418# ==============================================================================
1419# LOGGING CONFIGURATION
1420# ==============================================================================
1421logging_driver: "json-file"
1422logging_max_size: "10m"
1423logging_max_file: "3"
1424
1425# Service-specific logging levels (unified LLM stack uses llm_stack_* variables)
1426# ollama_log_level: "info" # Now uses llm_stack_ollama_log_level
1427# openwebui_log_level: "info" # Now uses llm_stack_openwebui_log_level
1428# litellm_log_level: "info" # Now uses llm_stack_litellm_log_level
1429frigate_log_level: "info"
1430immich_log_level: "log"
1431forgejo_log_level: "Info"
1432ghost_logging: "info"
1433
1434
1435# ==============================================================================
1436# MACRO CHECKER (natural-language calorie & macro tracker)
1437# ==============================================================================
1438# Repository: yannick/my_macro_checker on Forgejo. Its CI builds and pushes
1439# `macro-checker:<sha>` and `:latest` to Harbor, like the dnd images; this role
1440# only pulls. One compose project: the app, its PostgreSQL, a nightly pg_dump
1441# sidecar and a private SearXNG for the web-lookup fallback.
1442macros_enabled: false
1443macros_config_dir: "/docker/macros"
1444
1445macros_registry: "{{ vault_runner.harbor_hostname | default('ys-registry.com') }}"
1446macros_registry_project: "docker"
1447macros_registry_username: "{{ (vault_macros | default({})).registry_username | default('admin') }}"
1448macros_registry_password: >-
1449 {{ (vault_macros | default({})).registry_password
1450 | default(vault_runner.harbor_admin_password) }}
1451macros_image_tag: "latest"
1452macros_image: "{{ macros_registry }}/{{ macros_registry_project }}/macro-checker:{{ macros_image_tag }}"
1453
1454# 8110: clear of Tandoor (8085), the Almanac (8099) and the web tools (809x).
1455macros_port: 8110
1456
1457# PostgreSQL on local disk, NOT the NFS mount: Postgres and NFS locking do not
1458# mix. The backups go to the share instead, where restic picks them up.
1459macros_db_dir: "{{ macros_config_dir }}/db"
1460macros_backup_dir: "{{ runner_nfs_mount_dir }}/macros/backups"
1461macros_backup_keep_days: 30
1462
1463# The Dutch Food Composition Database (NEVO). Its conditions of use forbid
1464# redistribution, so it is in neither the app image nor this repository: it is
1465# copied from the controller when present and mounted read-only. The app
1466# imports it on start, once per dataset version.
1467#
1468# Download it (free, after accepting RIVM's conditions) from
1469# https://www.rivm.nl/en/dutch-food-composition-database/use-of-nevo-online/request-dataset
1470# and point macros_nevo_src at the CSV from the zip.
1471macros_nevo_file: "NEVO2025_v9.0.csv"
1472macros_nevo_src: "{{ lookup('env', 'HOME') }}/personal/my_macro_checker/data/nevo/{{ macros_nevo_file }}"
1473macros_nevo_dir: "{{ macros_config_dir }}/nevo"
1474
1475# The language model, through LiteLLM. By the host address rather than the
1476# container name, for the same reason as DND_LLM_BASE_URL: `litellm` resolves
1477# only inside llm-stack-net.
1478macros_llm_base_url: "http://{{ ansible_default_ipv4.address }}:{{ llm_stack_litellm_port }}/v1"
1479macros_llm_api_key: "{{ (vault_macros | default({})).llm_api_key | default(vault_runner.lite_llm_master_key) }}"
1480# qwen3:8b parses the meal and picks database rows; it never supplies numbers.
1481# Measured: 8b and 14b both parse correctly, 4b returns nothing.
1482macros_llm_model: "qwen3:8b"
1483
1484# Food sources that can be switched off, e.g. if the unofficial Albert Heijn
1485# API changes shape. Products already logged stay in the local database.
1486macros_enable_ah: true
1487macros_enable_off: true
1488macros_enable_web_search: true
1489macros_searxng_image: "docker.io/searxng/searxng:2026.10.16-ff8f5ddcc"
1490
1491# Single-user login and session signing. From the vault.
1492macros_password: "{{ (vault_macros | default({})).password | default('') }}"
1493macros_secret_key: "{{ (vault_macros | default({})).secret_key | default('') }}"
1494macros_postgres_password: "{{ (vault_macros | default({})).postgres_password | default('') }}"
1495macros_searxng_secret: "{{ (vault_macros | default({})).searxng_secret | default('') }}"
1496# Off while served over plain HTTP; turn on behind TLS or the cookie is never sent.
1497macros_secure_cookie: false
1498
1499# Mealie link: import Mealie recipes as batches and write nutrition back.
1500# Container to container over runner_docker_network (both services join it);
1501# the public URL is only for "Open in Mealie" links in the browser.
1502macros_mealie_enabled: "{{ mealie_enabled | default(false) }}"
1503macros_mealie_url: "http://mealie:9000"
1504macros_mealie_public_url: "{{ mealie_base_url | default('') }}"
1505# A long-lived token of the Mealie admin (API tokens are created in Mealie
1506# under the user profile, or with POST /api/users/api-tokens).
1507macros_mealie_api_token: "{{ (vault_macros | default({})).mealie_api_token | default('') }}"
1508# openScale sync (phone) posts scale readings to /api/integrations/openscale
1509# with "Authorization: Bearer <token>". Empty leaves the webhook disabled.
1510macros_openscale_token: "{{ (vault_macros | default({})).openscale_token | default('') }}"
1511# Zepp account for the wearable sync (signs in like zepp.com, so the phone
1512# app stays signed in). Empty disables the sync.
1513macros_zepp_email: "{{ (vault_macros | default({})).zepp_email | default('') }}"
1514macros_zepp_password: "{{ (vault_macros | default({})).zepp_password | default('') }}"
1515
1516# ==============================================================================
1517# MEALIE (recipe manager; runs alongside Tandoor for now)
1518# ==============================================================================
1519# Upstream image, pinned: Mealie advises a version tag and reading the release
1520# notes before moving it (v3.26 hardened outbound HTTP, v3.25 changed tokens).
1521mealie_enabled: false
1522mealie_version: "v3.27.0"
1523mealie_image: "ghcr.io/mealie-recipes/mealie:{{ mealie_version }}"
1524mealie_config_dir: "/docker/mealie"
1525
1526# 9925 is upstream's documented host port, and free here.
1527mealie_port: 9925
1528# Links in notifications and the OIDC callback. What a browser types.
1529mealie_base_url: "http://{{ ansible_host | default('runner.home') }}:{{ mealie_port }}"
1530
1531# Recipe images and assets on local disk beside the database, like Tandoor.
1532# PostgreSQL on local disk, not NFS (Postgres and NFS locking do not mix).
1533# Nightly backups of both go to the share.
1534mealie_data_dir: "{{ mealie_config_dir }}/data"
1535mealie_db_dir: "{{ mealie_config_dir }}/db"
1536mealie_backup_dir: "{{ runner_nfs_mount_dir }}/mealie/backups"
1537mealie_backup_keep_days: 30
1538
1539# Mealie drops to PUID/PGID inside the container; match the runner files.
1540mealie_puid: 1000
1541mealie_pgid: 100
1542# Upstream recommends a limit: Python otherwise pre-allocates generously and
1543# idles high on a machine with this much RAM.
1544mealie_memory_limit: "1000M"
1545
1546mealie_allow_signup: false
1547# Login lifetime in hours. 720 = 30 days, so the phone stays signed in.
1548mealie_token_time: 720
1549
1550# The first admin. On a fresh database Mealie creates [email protected] /
1551# MyPassword; the deploy logs in with that once and moves the account to these
1552# values, so the default never stays reachable.
1553mealie_admin_email: "{{ (vault_mealie | default({})).admin_email | default('') }}"
1554mealie_admin_username: "{{ (vault_mealie | default({})).admin_username | default('admin') }}"
1555mealie_admin_full_name: "{{ (vault_mealie | default({})).admin_full_name | default('Admin') }}"
1556mealie_admin_password: "{{ (vault_mealie | default({})).admin_password | default('') }}"
1557mealie_postgres_password: "{{ (vault_mealie | default({})).postgres_password | default('') }}"
1558
1559# --- units ------------------------------------------------------------------------
1560# A fresh Mealie has no units, so its parser cannot read "550 g". The deploy
1561# loads Mealie's unit seed once (en-GB: metric names). Foods and labels are NOT
1562# seeded from Mealie -- its list is US-derived -- but pushed by the Macro
1563# Checker from NEVO and your confirmed products. Empty disables unit seeding.
1564mealie_seed_locale: "en-GB"
1565# Shown abbreviated ("550 g"). The abbreviation is filled in when a unit has
1566# none -- a unit created by hand before seeding keeps its row but gets one.
1567# Units Mealie's seed lacks but American recipes use.
1568mealie_extra_units:
1569 - { name: stick, pluralName: sticks, abbreviation: "", description: "of butter, 113 g" }
1570
1571mealie_abbreviated_units:
1572 gram: g
1573 kilogram: kg
1574 milligram: mg
1575 millilitre: ml
1576 litre: l
1577 tablespoon: tbsp
1578 teaspoon: tsp
1579
1580# --- AI through the local LLM stack ------------------------------------------
1581# Mealie keeps AI providers in its database (per group, since v3.x), not in
1582# the environment, so the deploy creates them through the API and points them
1583# at LiteLLM. By host address, for the same reason as DND_LLM_BASE_URL.
1584mealie_ai_enabled: true
1585mealie_ai_base_url: "http://{{ ansible_default_ipv4.address }}:{{ llm_stack_litellm_port }}/v1"
1586mealie_ai_api_key: "{{ (vault_mealie | default({})).llm_api_key | default(vault_runner.lite_llm_master_key) }}"
1587# Seconds per request. Recipe imports take ~20 s warm; a cold model load and
1588# a long page can take minutes on one 3060.
1589mealie_ai_timeout: 300
1590# Default provider: imports, the AI ingredient parser, organizers.
1591# qwen3:14b, not 8b: on a 22-ingredient chili 8b rewrote "400 g carrots" as
1592# "40, g carrots" and once lumped every ingredient into one row. 14b got it
1593# right, at ~95 s per import instead of ~16 s -- imports are rare, wrong
1594# numbers end up in your macros.
1595mealie_ai_default_model: "qwen3:14b"
1596# Image provider: recipe-from-photo. Needs a vision model routed through
1597# ollama_chat in LiteLLM (see llm_stack_models). Empty disables image imports.
1598mealie_ai_image_model: "qwen2.5vl:7b"
1599# --- speech-to-text for video imports (TikTok, Instagram, YouTube) ------------
1600# Mealie transcribes a video only when an audio provider is set; without one it
1601# reads the page, finds no recipe and fails. LiteLLM has no Whisper, and the
1602# dnd speech-to-text service is no fit: its model is English-only
1603# (distil-large-v3) and the GPU is full. So Mealie gets its own Speaches on the
1604# CPU with a multilingual model: a one-minute reel in ~10-20 s on this box.
1605mealie_stt_enabled: true
1606mealie_stt_image: "ghcr.io/speaches-ai/speaches:0.8.3-cpu"
1607mealie_stt_model: "Systran/faster-whisper-small"
1608mealie_stt_model_dir: "{{ mealie_config_dir }}/stt-models"
1609# Published on localhost only, for the model download below; Mealie reaches it
1610# over the compose network.
1611mealie_stt_port: 9011
1612# Unload an idle model after this many seconds; reels are occasional.
1613mealie_stt_model_ttl: 600
1614mealie_stt_memory_limit: "3g"
1615
1616# Mealie never returns API keys, so a changed key cannot be detected. Run once
1617# with -e mealie_ai_sync_keys=true after rotating the LiteLLM key.
1618mealie_ai_sync_keys: false
1619
1620# ==============================================================================
1621# CONTAINER IMAGES (fallback pins)
1622# ==============================================================================
1623# The authoritative pins live in server_automation/group_vars/all/images.yml,
1624# which replaces this whole dict (a dict from group_vars is not merged). Kept
1625# identical here so the role stays deterministic on its own. Never define
1626# images in vars/main.yml: role vars beat group_vars.
1627container_images:
1628 dnd_stt: "ghcr.io/speaches-ai/speaches:0.8.3-cuda"
1629 ollama: "docker.io/ollama/ollama:0.32.6"
1630 openwebui: "ghcr.io/open-webui/open-webui:v0.11.3"
1631 litellm: "ghcr.io/berriai/litellm:v1.96.2"
1632 frigate: "ghcr.io/blakeblackshear/frigate:0.16.1"
1633 frigate_tensorrt: "ghcr.io/blakeblackshear/frigate:0.16.1-tensorrt"
1634 immich_server: "ghcr.io/immich-app/immich-server:v2.3.1"
1635 immich_machine_learning: "ghcr.io/immich-app/immich-machine-learning:v2.3.1-cuda"
1636 immich_postgres: "ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0"
1637 valkey: "docker.io/valkey/valkey:8.1.3-bookworm"
1638 forgejo: "codeberg.org/forgejo/forgejo:12.0.2"
1639 forgejo_runner: "data.forgejo.org/forgejo/runner:9.1.1"
1640 stirling_pdf: "docker.io/frooodle/s-pdf:1.2.0"
1641 convertx: "ghcr.io/c4illin/convertx:v0.17.0"
1642 cyberchef: "ghcr.io/gchq/cyberchef:10.23.0"
1643 it_tools: "docker.io/corentinth/it-tools:2024.10.22-7ca5933"
1644 tandoor: "docker.io/vabene1111/recipes:2.0.3"
1645 ghost: "docker.io/library/ghost:5.130.4-alpine"
1646 livekit: "docker.io/livekit/livekit-server:v1.13.5"
1647 postgres: "docker.io/library/postgres:15.17-alpine"
1648 mysql: "docker.io/library/mysql:8.0.43"
1649llm_stack_postgres_image: "docker.io/library/postgres:15.17-alpine"
1650macros_postgres_image: "docker.io/library/postgres:16.15-alpine"
1651mealie_postgres_image: "docker.io/library/postgres:17.11-alpine"
1652