/
/
/
Ansible role that deployes services on my runner machine
1# D&D session agent stack.
2#
3# One compose project so the whole system has a single on/off switch:
4# dnd_enabled: false -> every part of it goes away.
5#
6# Services with deliberately different lifecycles:
7#
8# dnd-chamberlain always up. Owns session lifecycle and starts and stops
9# everything below. The only service a deploy starts, along
10# with dnd-stt.
11# dnd-stt always up. A generic OpenAI-compatible speech endpoint that
12# anything on the runner can use, not just this stack.
13# dnd-agent per session, behind the "session" profile.
14# dnd-table-runner per session, behind the "session" profile.
15# dnd-post runs to completion and exits, behind the "post" profile.
16#
17# Everything with a profile is the Chamberlain's to start. A plain `up -d` or an
18# Ansible deploy brings up only the two always-on services.
19
20services:
21 # The Chamberlain: the only always-on part of this stack. It creates campaigns
22 # and sessions, opens the LiveKit room, and starts and stops every other
23 # service here around a session.
24 dnd-chamberlain:
25 container_name: dnd-chamberlain
26 image: "{{ dnd_chamberlain_image }}"
27 restart: unless-stopped
28
29 # No profile, deliberately: this is the one service a plain `up -d` and an
30 # Ansible deploy should start.
31
32 env_file:
33 - chamberlain.env
34
35 # The host runs UTC, so the mounted /etc/localtime alone reproduces UTC --
36 # and the activity log is read by people who think in the wall clock. TZ
37 # overrides it; the zoneinfo mount is what lets glibc resolve the name.
38 environment:
39 - TZ={{ runner_timezone }}
40
41 # Runs as root -- it must, to use the Docker socket -- but the campaign
42 # directories and manifests it creates on the NFS mount must NOT be
43 # root-owned: the post-processor runs as {{ dnd_uid }}:{{ dnd_gid }} and has
44 # to write into them, and a root-owned tree cannot be tidied up by hand
45 # afterwards.
46 #
47 # `group_add` plus the setgid bit the parent directories already carry means
48 # everything it creates lands in the shared group, and the umask makes it
49 # group-writable. Running the whole container as {{ dnd_uid }} instead would
50 # lose the socket.
51 user: "0:{{ dnd_gid }}"
52 group_add:
53 - "{{ dnd_gid }}"
54
55 ports:
56 - "{{ chamberlain_http_port }}:{{ chamberlain_http_port }}"
57
58 volumes:
59 - /etc/localtime:/etc/localtime:ro
60 - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
61 # The media root, at the same path as every other service sees it. That
62 # identity is what lets a path computed here be used verbatim in the
63 # containers this starts -- a deployment invariant, not a coincidence.
64 - {{ dnd_media_dir }}:/media/dnd
65 # The Docker socket. This is what makes the Chamberlain able to start and
66 # stop the services below, and it is also root on the host -- see the
67 # security note in the role's README. Read-write: it creates containers.
68 - /var/run/docker.sock:/var/run/docker.sock
69 # The compose project it drives, mounted at the same path it has on the
70 # host so `docker compose --project-directory` resolves identically
71 # whether run from inside this container or from a shell on the runner.
72 - {{ dnd_config_dir }}:{{ dnd_config_dir }}
73
74 networks:
75 {{ runner_docker_network }}:
76 aliases:
77 - dnd-chamberlain
78
79 dnd-almanac:
80 container_name: dnd-almanac
81 image: "{{ dnd_almanac_image }}"
82 restart: unless-stopped
83
84 # No profile, like the Chamberlain: this is a long-lived service players
85 # reach, not a per-session container the Chamberlain starts and stops.
86
87 # **Deliberately NOT given the Docker socket.** That is the whole reason
88 # this service exists separately from the Chamberlain: it is the one
89 # players can reach, so its blast radius is character data and nothing
90 # else. See docs almanac-plan.md.
91 environment:
92 - TZ={{ runner_timezone }}
93 - ALMANAC_DB=/data/almanac.db
94 - ALMANAC_SRD=/data/srd
95 # Set when this is served over TLS rather than plain HTTP on Tailscale.
96 - ALMANAC_SECURE_COOKIE={{ '1' if dnd_almanac_secure_cookie else '0' }}
97 # The Chamberlain presents this to administer users and membership.
98 # Empty closes that path rather than leaving it open.
99 - ALMANAC_ADMIN_TOKEN={{ dnd_almanac_admin_token }}
100 # Where the campaign directories live, for projection. Empty means
101 # projection answers 503 and explains itself, rather than guessing a path
102 # -- writing into the wrong tree is the one mistake it cannot undo.
103 - ALMANAC_MEDIA_ROOT={{ '/media/dnd' if dnd_almanac_projection else '' }}
104 # Gunicorn's control server writes into $HOME. As UID {{ dnd_uid }} the
105 # image's /root is unreachable and HOME defaults to /, so it logs
106 # "Permission denied: '/.gunicorn'" on every start -- serving correctly
107 # but erroring, which is exactly the kind of noise that hides a real
108 # fault later. The same trap the table service hit with Chromium.
109 - HOME=/data
110
111 # Runs as the shared dnd user so the database it creates on the NFS mount
112 # is not root-owned -- the same reasoning as the Chamberlain's `user:`,
113 # minus the socket that forces root there.
114 user: "{{ dnd_uid }}:{{ dnd_gid }}"
115
116 ports:
117 - "{{ dnd_almanac_port }}:8099"
118
119 volumes:
120 - /etc/localtime:/etc/localtime:ro
121 - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
122 # The database and the vendored SRD corpus.
123 - {{ dnd_almanac_data_dir }}:/data
124{% if dnd_almanac_projection %}
125 # The media root, granted by phase 3 so projection can write
126 # `characters/*.yml` and the `participants` block of `roster.yml`, and
127 # since 2026-10-08 so the DM can prepare a session's battlemaps and
128 # handouts under `table/`.
129 #
130 # **This is the one path by which a player-facing service can write
131 # outside its own database.** It is still not the Docker socket, which is
132 # the distinction this whole separate service exists to keep -- but it is
133 # a real widening, so it is behind its own flag rather than implied by
134 # the service being enabled at all.
135 #
136 # Writes are confined to three things, and each is narrow for its own
137 # reason:
138 #
139 # * sheets, written atomically and never deleted;
140 # * the roster merge, which refuses if it would drop a participant or
141 # touch `voices`/`labels`;
142 # * `table/maps` and `table/handouts`, which the table server already
143 # writes -- preparation is the same directories at a different
144 # moment, and a discard moves the file to `table/.discarded/` rather
145 # than unlinking it.
146 #
147 # All three are DM- or admin-gated in the application. A player with an
148 # account can reach none of them.
149 - {{ dnd_media_dir }}:/media/dnd
150{% endif %}
151
152 networks:
153 {{ runner_docker_network }}:
154 aliases:
155 - dnd-almanac
156
157 dnd-stt:
158 container_name: dnd-stt
159 image: "{{ container_images.dnd_stt }}"
160 restart: unless-stopped
161
162 ports:
163 - "{{ dnd_stt_host_port }}:8000"
164
165 env_file:
166 - stt.env
167
168 environment:
169 - TZ={{ runner_timezone }}
170
171 volumes:
172 - /etc/localtime:/etc/localtime:ro
173 - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
174 # Whisper weights are several hundred MB and fetched on first use, so
175 # this must persist or every restart re-downloads them.
176 - {{ dnd_stt_model_dir }}:/home/ubuntu/.cache/huggingface
177
178{% if gpu_enabled %}
179 runtime: nvidia
180 deploy:
181 resources:
182 reservations:
183 devices:
184 - driver: nvidia
185 count: 1
186 capabilities: [gpu]
187{% endif %}
188
189 healthcheck:
190 test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
191 interval: 30s
192 timeout: 10s
193 start_period: {{ dnd_stt_start_period }}
194 retries: 3
195
196 networks:
197 {{ runner_docker_network }}:
198 aliases:
199 - dnd-stt
200
201 dnd-agent:
202 container_name: dnd-agent
203 image: "{{ dnd_agent_image }}"
204
205 # Never restarted automatically. A session ends when people leave, and a
206 # restarting agent would silently rejoin an empty room and record silence.
207 restart: "no"
208 profiles: ["session"]
209
210 env_file:
211 - agent.env
212
213 # Session scoping, injected by the Chamberlain when it starts this container.
214 #
215 # The passthrough MUST be declared here. Compose only forwards a variable
216 # from its own environment into a container if the service names it, so
217 # without these lines the Chamberlain's injection is silently dropped and the
218 # agent falls back to whatever agent.env says -- which for the room is
219 # nothing at all. Verified against compose: a declared-but-unset variable
220 # also *overrides* env_file with an empty value, so these must never be
221 # duplicated in agent.env.
222 environment:
223 - LIVEKIT_ROOM
224 - DND_SESSION_DIR
225 - DND_CAMPAIGN_DIR
226 - DND_SESSION_NAME
227 - DND_SESSION_TITLE
228 - DND_CAMPAIGN_SLUG
229 - TZ={{ runner_timezone }}
230
231 depends_on:
232 dnd-stt:
233 condition: service_healthy
234
235 volumes:
236 - /etc/localtime:/etc/localtime:ro
237 - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
238 - {{ dnd_media_dir }}:/media/dnd
239
240 networks:
241 {{ runner_docker_network }}:
242 aliases:
243 - dnd-agent
244
245 dnd-post:
246 container_name: dnd-post
247 image: "{{ dnd_post_image }}"
248
249 restart: "no"
250 # Only started explicitly, so `docker compose up -d` brings up the STT
251 # service alone and never kicks off an hour of summarisation.
252 profiles: ["post"]
253
254 env_file:
255 - post.env
256
257 # Session scoping, as for dnd-agent above -- compose drops an injected
258 # variable that the service does not name.
259 environment:
260 - DND_SESSION_DIR
261 - DND_CAMPAIGN_DIR
262 - DND_SESSION_NAME
263 - DND_SESSION_TITLE
264 - DND_CAMPAIGN_SLUG
265 - TZ={{ runner_timezone }}
266
267 volumes:
268 - /etc/localtime:/etc/localtime:ro
269 - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
270 - {{ dnd_media_dir }}:/media/dnd
271 # pyannote weights, kept out of the image so a rebuild does not refetch
272 # them and so the gated download happens exactly once.
273 - {{ dnd_model_dir }}:/models
274
275{% if gpu_enabled %}
276 runtime: nvidia
277 deploy:
278 resources:
279 reservations:
280 devices:
281 - driver: nvidia
282 count: 1
283 capabilities: [gpu]
284{% endif %}
285
286 networks:
287 {{ runner_docker_network }}:
288 aliases:
289 - dnd-post
290{% if dnd_camera_enabled %}
291
292 # The camera receiver: takes the Pi's RTP stream, records it in session-scoped
293 # segments, and serves the MJPEG preview the DM page embeds.
294 dnd-camera:
295 container_name: dnd-camera
296 image: "{{ dnd_camera_image }}"
297
298 restart: "no"
299 profiles: ["session"]
300
301 # No privileges of any kind: it receives UDP and writes files. The media root
302 # is group-writable so this can run as the media owner rather than root.
303 user: "{{ dnd_uid }}:{{ dnd_gid }}"
304
305 env_file:
306 - camera.env
307
308 # Session scoping, injected by the Chamberlain. Declared here or compose
309 # drops it -- see the note on dnd-agent above.
310 environment:
311 - DND_SESSION_DIR
312 - DND_SESSION_NAME
313 - DND_CAMPAIGN_SLUG
314 - TZ={{ runner_timezone }}
315
316 ports:
317 # The RTP stream from the Pi. UDP, and published on the host because the
318 # Pi sends to the runner's address, not into a bridge network.
319 - "{{ dnd_camera_rtp_port }}:{{ dnd_camera_rtp_port }}/udp"
320 # The service's own HTTP face: the MJPEG preview, a single-frame grab,
321 # and /tracking/status.
322 #
323 # Published because it is the only way to SEE what the detector sees.
324 # Without it, checking the table meant standing up and looking at it, or
325 # spinning up a throwaway capture container -- and a second consumer on
326 # the RTP port starves the real one (pitfalls #23: SO_REUSEADDR lets both
327 # bind, the kernel delivers each datagram to exactly one). Publishing the
328 # service's own preview removes the reason to ever do that.
329 #
330 # http://runner.home:{{ dnd_camera_http_port }}/camera/stream live MJPEG
331 # http://runner.home:{{ dnd_camera_http_port }}/camera/frame.jpg one frame
332 # http://runner.home:{{ dnd_camera_http_port }}/tracking/status detector state
333 - "{{ dnd_camera_http_port }}:{{ dnd_camera_http_port }}"
334
335 volumes:
336 - /etc/localtime:/etc/localtime:ro
337 - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
338 - {{ dnd_media_dir }}:/media/dnd
339 # The camera-to-projector homography. Outlives the container by design:
340 # the two are on one rigid mount, so a calibration solved once holds
341 # until somebody unbolts something. This service is `restart: "no"` and
342 # recreated per session, which would otherwise lose it every evening.
343 - {{ dnd_config_dir }}/camera:/data
344
345 healthcheck:
346 # "the service is up and supervising", not "footage is arriving": with the
347 # projector asleep there is deliberately nothing to record, and a container
348 # marked unhealthy then would be restarted in a loop all evening.
349 test: ["CMD", "python3", "-c",
350 "import urllib.request; urllib.request.urlopen('http://localhost:{{ dnd_camera_http_port }}/health', timeout=5)"]
351 interval: "{{ health_check_interval }}"
352 timeout: "{{ health_check_timeout }}"
353 retries: {{ health_check_retries }}
354 start_period: "20s"
355
356 networks:
357 {{ runner_docker_network }}:
358 aliases:
359 - dnd-camera
360{% endif %}
361{% if dnd_table_enabled %}
362
363 # The table server: renders the kiosk page with headless Chromium and streams
364 # it to the Pi, which only hardware-decodes it.
365 dnd-table-runner:
366 container_name: dnd-table-runner
367 image: "{{ dnd_table_runner_image }}"
368
369 # Started and stopped by the Chamberlain, per session -- so `restart: "no"`
370 # and a profile, exactly like dnd-agent beside it.
371 #
372 # This used to be `unless-stopped`, on the argument that bringing a video
373 # pipeline up from cold cost a visible moment. That argument belongs to the
374 # *Pi's* display pipeline, which does stay up permanently: what it avoids is
375 # DRM mode negotiation and a tty handover. On this side a cold start is only
376 # Chromium and x264 launching -- a few seconds before the first frame, paid
377 # once when a session starts, and the Chamberlain waits for /api/state to
378 # answer before reporting the session live.
379 #
380 # The profile is what stops `docker compose up -d` and an Ansible deploy
381 # from starting it: with a profile set, it is only started when named
382 # explicitly with that profile active.
383 restart: "no"
384 profiles: ["session"]
385
386 # Match the media directory's owner so uploaded handouts and rendered
387 # snapshots are readable from the NAS side. Root-owned files on a shared
388 # mount are a nuisance to clean up later.
389 user: "{{ dnd_uid }}:{{ dnd_gid }}"
390
391 environment:
392 TABLE_WIDTH: "{{ dnd_table_width }}"
393 TABLE_HEIGHT: "{{ dnd_table_height }}"
394 TABLE_FPS: "{{ dnd_table_fps }}"
395 TABLE_BITRATE_KBPS: "{{ dnd_table_bitrate_kbps }}"
396 TABLE_PI_HOST: "{{ dnd_table_pi_host }}"
397 TABLE_RTP_PORT: "{{ dnd_table_rtp_port }}"
398 TABLE_HTTP_PORT: "{{ dnd_table_http_port }}"
399 # Overridden per session by the Chamberlain, which points it at the live
400 # campaign's media tree. The value here is the fallback for a table server
401 # started by hand outside a session.
402 TABLE_MEDIA_ROOT: "${TABLE_MEDIA_ROOT:-/media}"
403 # Creature tokens and saved encounters. **Deliberately NOT under the
404 # media root**: the set is over a thousand files, and the media library
405 # is scanned with an unfiltered rglob, so putting them together would
406 # bury every map and handout the DM owns. Which set is mounted is
407 # `dnd_token_dir` -- see roles/runner/defaults/main.yml.
408 #
409 # Not session-scoped either, unlike the media root -- a goblin is a
410 # goblin in every campaign, and re-deploying 600 MB per session would be
411 # pure waste. Encounters live alongside them and are equally reusable.
412 TABLE_TOKEN_ROOT: "/tokens"
413 TABLE_SESSION_GROUP: "${TABLE_SESSION_GROUP:-}"
414 TABLE_SELF_URL: "http://localhost:{{ dnd_table_http_port }}"
415 # Where the DM page's camera preview is proxied from. Often absent -- the
416 # receiver is per-session -- which the page reports rather than erroring.
417 TABLE_CAMERA_URL: "http://dnd-camera:{{ dnd_camera_http_port }}"
418 # The Chamberlain, which owns the session. The Ekkyklemeon starts,
419 # pauses and resumes the evening by calling its own backend, which
420 # forwards here -- the same one-origin reasoning as the camera preview.
421 #
422 # **A real dependency, named rather than hidden**: the Chamberlain
423 # created this container, so a Chamberlain that is down has already
424 # taken the table with it.
425 TABLE_CHAMBERLAIN_URL: "http://dnd-chamberlain:{{ chamberlain_http_port }}"
426 # The Almanac, for turning a remote player's session cookie into a
427 # person. **Direct rather than through the Chamberlain**, unlike every
428 # other call this server makes to it: the Chamberlain holds an admin
429 # token and speaks for itself, whereas this has to speak for the
430 # *browser*, carrying that browser's cookie, because the question is
431 # "who is this" and only the cookie's owner can answer it.
432 #
433 # Empty disables player identification entirely, leaving the DM (on a
434 # trusted network) and the read-only table viewer -- which is what this
435 # server did before remote players existed.
436 TABLE_ALMANAC_URL: "http://dnd-almanac:{{ dnd_almanac_port }}"
437 # Which networks the DM screen is served to without a login.
438 #
439 # **The exemption is a decision, not an accident of deployment.** The DM
440 # screen exists only while the room is full, and a session expiring
441 # mid-combat is a worse failure than the one a login would prevent.
442 # Everything a remote player reaches is authenticated instead.
443 #
444 # Set this to the empty string on a deployment reachable from outside
445 # the house: every viewer then has to identify itself, the DM included.
446 TABLE_TRUSTED_NETWORKS: "{{ dnd_table_trusted_networks }}"
447 TABLE_MQTT_HOST: "{{ mqtt_host }}"
448 TABLE_MQTT_PORT: "{{ mqtt_port }}"
449 TABLE_MQTT_USERNAME: "{{ mqtt_username }}"
450 TABLE_MQTT_PASSWORD: "{{ mqtt_password }}"
451 TABLE_BASE_TOPIC: "dnd"
452 # Chromium, Xvfb and lgpio all want a writable HOME. As a non-root user
453 # the image's /root is unreachable, and the failure is an opaque crash
454 # rather than a permission message.
455 HOME: "/tmp"
456 XDG_RUNTIME_DIR: "/tmp"
457 XDG_CACHE_HOME: "/tmp/.cache"
458 XDG_CONFIG_HOME: "/tmp/.config"
459 TZ: "{{ runner_timezone }}"
460
461 ports:
462 - "{{ dnd_table_http_port }}:{{ dnd_table_http_port }}"
463
464 volumes:
465 - /etc/localtime:/etc/localtime:ro
466 - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
467 # Writable: the DM uploads handouts through the web UI mid-session, and
468 # rendered URL snapshots are written here too. Read-only was the right
469 # call when the library could only be filled from the NAS side.
470 - "{{ dnd_table_media_dir }}:/media"
471 # Writable, because the DM uploads their own tokens and saves encounters
472 # through the web UI. The shipped creature set under library/ is only
473 # ever read; custom/ and encounters/ are written.
474 - "{{ dnd_token_dir }}:/tokens"
475
476 # Chromium's renderer wants more than the 64MB default, and it fails in
477 # confusing ways rather than cleanly when it runs out.
478 shm_size: "512m"
479
480 healthcheck:
481 test: ["CMD", "python3", "-c",
482 "import urllib.request; urllib.request.urlopen('http://localhost:{{ dnd_table_http_port }}/api/state', timeout=5)"]
483 interval: "{{ health_check_interval }}"
484 timeout: "{{ health_check_timeout }}"
485 retries: {{ health_check_retries }}
486 start_period: "60s"
487
488 networks:
489 {{ runner_docker_network }}:
490 aliases:
491 - dnd-table-runner
492{% endif %}
493
494{% if dnd_testfeed_enabled | default(false) %}
495
496 # The test feeder: publishes a recorded episode into the session's room as
497 # two participants, so the deployed agent runs end to end against real
498 # material. Test scaffolding, deliberately behind its own profile and its
499 # own enable flag -- it must never start as part of a normal session.
500 #
501 # It uses the agent's image rather than one of its own. The feeder ships as
502 # a second console script in `dnd-agent`, because it shares the LiveKit
503 # dependency and the participant-name rules with the agent it exercises, and
504 # a separate image would be one more thing to build, push, pull and keep in
505 # step for no gain.
506 dnd-testfeed:
507 container_name: dnd-testfeed
508 image: "{{ dnd_agent_image }}"
509 entrypoint: ["dnd-testfeed"]
510
511 # Never restarted. A feeder that came back by itself would republish an
512 # episode into a room that had moved on, and the second run's audio would
513 # be interleaved with the first's -- which reads as the agent making
514 # incoherent decisions rather than as a duplicate feed.
515 restart: "no"
516 profiles: ["test"]
517
518 env_file:
519 - testfeed.env
520
521 # Session scoping, exactly as for dnd-agent. The passthrough MUST be
522 # declared here: compose only forwards a variable it names, and a
523 # declared-but-unset one *overrides* env_file with an empty string. So
524 # these must not also appear in testfeed.env.
525 environment:
526 - LIVEKIT_ROOM
527 - DND_SESSION_DIR
528 - DND_CAMPAIGN_DIR
529 - DND_SESSION_NAME
530 - DND_SESSION_TITLE
531 - DND_CAMPAIGN_SLUG
532 # Recorded in run.yml so a cues.jsonl can be traced to what produced it.
533 - DND_AGENT_IMAGE
534 - TZ={{ runner_timezone }}
535
536 volumes:
537 - /etc/localtime:/etc/localtime:ro
538 - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
539 # The media root, at the same path every other service sees it -- which
540 # is what lets the Chamberlain pass a path it computed straight through
541 # as --episode and --session-dir.
542 - {{ dnd_media_dir }}:/media/dnd
543{% if dnd_testfeed_host_dir != dnd_media_dir + '/test' %}
544 # The episodes, when they live outside the media root. Read-only: this is
545 # source material for many runs and nothing here writes to it.
546 - {{ dnd_testfeed_host_dir }}:{{ dnd_testfeed_dir }}:ro
547{% endif %}
548
549 networks:
550 {{ runner_docker_network }}:
551 aliases:
552 - dnd-testfeed
553{% endif %}
554
555networks:
556 {{ runner_docker_network }}:
557 external: true
558