ADR-0008's media tier — the household's media and music servers — on smaug
(10.0.40.30, CasaBonita / VLAN 40), the ThinkServer TS150 that #413 bought
and ADR-0040 gave TrueNAS. Deployed 2026-09-19, from a copy of this
directory's two files on the pool, under TrueNAS's own Docker;
build-the-nas.md §6 is the procedure. make up STACK=media does not
work on that host — it renders secrets this stack does not have, on a box
without make or sops — so a change here reaches smaug by re-fetching
compose.yaml and .env.example from main into /mnt/erebor/apps/stack
and running docker compose up -d there. Nothing pulls from main on that
host on its own: a Dependabot bump is merged here and deployed there by hand.
A third file lives beside them since ADR-0047: scripts/collect-smart-state.sh,
fetched the same way and run by TrueNAS's cron — a change to it reaches
smaug only by the same re-fetch. A fourth, scripts/collect-truenas-version.sh,
is the same shape for the product version check-versions compares
(#616, build-the-nas.md §6.7). A fifth, scripts/collect-zpool-state.sh,
writes every ZFS leaf's state every five minutes, so a faulted disk under a pool
that still reads ONLINE pages
(#744, build-the-nas.md §6.8).
cd /mnt/erebor/apps/stack && docker compose up -d.env.example names JELLYFIN_CONFIG_PATH, AUDIOBOOKSHELF_STATE_PATH and
NAVIDROME_DATA_PATH, directories on erebor/apps that have to exist, owned
by 65534:65534, before the first up — §6 and §6.5 of the runbook create
them, and say why they are bind mounts and not volumes.
| Service | Image | Port | Purpose |
|---|---|---|---|
| jellyfin | jellyfin/jellyfin |
8096 (http), on the segment | The media server the televisions reach directly, with Quick Sync hardware transcoding on the E3-1225 v6's HD P630 (#138, ADR-0016) |
| audiobookshelf | ghcr.io/advplyr/audiobookshelf |
13378 (http), to Hicks through the 13378 pass | Audiobooks, with listening progress that syncs between a person's devices (#140, ADR-0050). Deployed 2026-09-29 — build-the-nas.md §6.5 |
| navidrome | deluan/navidrome |
4533 (http), to Hicks through the 4533 pass | The music server, over the Subsonic API, for the apps on the phones (#141). Deployed 2026-09-30 — build-the-nas.md §6.6 |
| node-exporter | prom/node-exporter |
9100 (http), to 10.0.99.20 only |
How this host is monitored at all — Prometheus scrapes it, because nothing on this segment may push (#256, ADR-0016); it also serves the SMART textfile a root cron job on the host writes (#483, ADR-0047) |
Four services, and three of them are the tier. node-exporter is here
because of the section below.
Jellyfin is the one video service, and that is a decision rather than a
starting point. ADR-0016
built Jellyfin alone and deferred Plex against one test, whether any screen
on 40 has no working Jellyfin client. The test was run by 2026-09-28: the LG
OLED, a console, and the phones and tablets all play from Jellyfin, and the
Xumo box is not used for the library. ADR-0056 declines Plex. It
authenticates its clients through plex.tv even on a local network, which
would make a household service that works today depend on a third party
staying up and keeping its terms. Audiobookshelf (#140) joined Jellyfin on
ADR-0008's test — an audiobook library is the same kind of data in the same
place — for the one thing Jellyfin does not do: progress that follows a
listener from a phone to a car to a speaker. Audiobooks only: the library
is mounted read-only, so podcast auto-download, the one feature that would
make it fetch on a schedule and write into erebor/media, is deferred by
ADR-0050 rather than left switched off by default.
Navidrome is here for music, which is the weakest thing Jellyfin does
(#141). It speaks the Subsonic API, so the clients are a dozen mature apps
on every platform that this repository will never maintain, and it reads the
same pool from erebor/media/music, read-only. Its clients are phones on
Hicks, like Audiobookshelf's.
stacks/sensitive puts seventeen services behind Caddy and publishes exactly one
port, because that tier holds the data whose loss hurts. This tier is the
other half of ADR-0008's bargain and gets the opposite treatment on
purpose:
a failure of the media box costs a film night, and a failure of the sensitive box costs a restore — never both at once.
Its clients are televisions, and a television is not a browser. It speaks to Jellyfin on 8096 on the same broadcast domain, with no firewall rule involved at all — which is the whole of what ADR-0008 bought by placing the server with its clients instead of punching a hole through a terminal segment to reach it. A proxy in front of that would add a hop, a certificate every television would have to trust, and a second thing to be down.
| Who | Reaches it how |
|---|---|
| Televisions on CasaBonita | Natively, same broadcast domain — the firewall never sees the packet |
| A Hicks workstation | Two of the rules in build-the-nas.md §0.5 — 50 → 10.0.40.30:443 and 50 → 10.0.40.30:8096, one per port |
A Hicks phone, on 13378 |
50 → 10.0.40.30:13378, Allow 13378 to smaug — the fifth by ADR-0050's count and the seventh to exist — created by build-the-nas.md §6.5, and reached from a Hicks workstation on 2026-09-29 (ADR-0050) |
Prometheus, on 9100 |
A third — 10.0.99.20 → 10.0.40.30:9100 |
Prometheus, on 22 |
The fourth rule — 10.0.99.20 → 10.0.40.30:22, inert until build-the-nas.md §6.2 switches SSH on for the backup pull, as frodo with one key and read access to erebor/apps (ADR-0045) |
A Hicks phone, on 4533 |
50 → 10.0.40.30:4533, Allow 4533 to smaug, for Navidrome — created 2026-09-22, ahead of the service and of the 13378 pass, so it is the fifth that exists; 445 is the sixth, and 13378 the seventh (§6.6) |
A Hicks workstation, on 445 |
50 → 10.0.40.30:445, Allow SMB to smaug, to mount the media share as samwise — created 2026-09-23 by build-the-nas.md §5's Workstations steps, and mounted from a Hicks workstation (ADR-0051) |
Saruman, on 2049 |
10.0.30.110 → 10.0.40.30:2049, Allow NFS from Saruman to smaug, to mount erebor/iso as the ISO store. Not the media stack's, and listed because it is a way into this host. Created 2026-10-01 by build-the-nas.md §5b, and mounted by Saruman (ADR-0072) |
| Everything else on the estate | Not at all — default deny |
ADR-0012 asks for a named off-host consumer before a port is published, and
here there are five: every screen in the house, one workstation, the phones,
the monitoring host, and Saruman, which mounts the ISO store and is the
only consumer from ImaginationLAN (ADR-0072). The phones are the first consumers of a service on
this host that are across a segment boundary rather than on it — Jellyfin's
case for publishing to the segment was that its clients live there, and
Audiobookshelf's and Navidrome's do not.
No published port is private to its consumer, and the reason is the same
for all three: everything already on CasaBonita shares this broadcast domain
and reaches them without the firewall seeing a packet. For 8096 that is the
whole point. For 13378 and 4533 it costs nothing, because nothing there
answers without a login. For 9100 it is a residual — an unauthenticated read
of this host's filesystems, uptime and load, by the televisions — and
docs/security.md records it rather than the firewall rule being mistaken
for a boundary it is not.
The NFS export on 2049 is not one of those three media ports, and it
differs in kind. A device on CasaBonita can open the port, but the export
itself admits 10.0.30.110 alone, so the share answers only Saruman. The
host scope is enforced twice, by the firewall rule and by the export, and
only the export applies on the segment.
Every other machine in the estate runs Alloy and pushes metrics and logs to
10.0.99.20. This one may not. ADR-0016 put the NAS on a segment that is
terminal outward — nothing on CasaBonita initiates anywhere — so Prometheus
reaches in over 99 → 40:9100 and scrapes instead. The convention that every
host runs Alloy exists to serve a direction; here the direction reverses, so
the tool does too. smaug is the estate's first scraped host.
#256 settled what shape that target takes, because ADR-0040 opened a fork
in it: TrueNAS ships a metrics endpoint of its own. The answer is
node_exporter, for three reasons written out in full in
stacks/observability/prometheus/targets/node.yaml — the firewall pass already
exists for 9100, the host-overview dashboard and seven rules in
host.rules.yaml are built on the node_* namespace, and a container in this
repository stays inside Dependabot, the digest pins and make validate, which
is ADR-0040 decision 2's argument for this stack being here at all.
What this does not buy is logs. Loki has no pull, and its ingest is
unauthenticated by ADR-0012, so centralising this host's logs would mean a
40 → 99:3100 rule that lets anything reaching the NAS write to the log store.
The NAS gets metrics and no logs; #255 is where that residual lives.
The exporter above reads nothing a drive says: it is uid 65534, read-only and
cap-dropped, and its image has no smartctl. The estate's SMART collector
needs root. Everywhere else it is a systemd timer installed beside Alloy;
this host has an immutable root and no Alloy, so ADR-0047 runs the same
script from a copy on the pool as a root cron job in TrueNAS's own UI,
writing smart-state-smaug.prom into SMART_TEXTFILE_PATH, which the
exporter bind-mounts read-only at /textfile. The series ride the scrape
that already exists, carry host="smaug" and instance="smaug", and every
SMART rule in host.rules.yaml applies unchanged — including
SmartDriveBadSectors, which holds the boot SSD to the four static
reallocated sectors recorded for it in scripts/render-smart-baselines.sh
and pages above them, with the growth rule armed on its own account.
Nothing on this host initiates anything: the file is local
and Prometheus reads it. build-the-nas.md §6.4 is the procedure, with
the cron job's fields recorded there.
Patch state is deliberately not collected here. TrueNAS is an appliance
updated as an image from its own UI; there is no apt to ask, so the
estate's homelab_apt_* rules have no referent on this host and a collector
would report nothing. ADR-0047 writes the no down rather than leaving it
to be rediscovered.
Jellyfin's, Audiobookshelf's and Navidrome's state is backed up. The library is not. That is deliberate, it is what #138 demanded a deliberate answer on, and it is carried in the volume layout rather than in a policy document:
| Volume / mount | What it holds | Backed up |
|---|---|---|
${JELLYFIN_CONFIG_PATH} → /config |
database, users, watch history, resume positions, metadata | yes — scripts/backup-nas.sh, weekly, from a ZFS snapshot of erebor/apps |
${AUDIOBOOKSHELF_STATE_PATH}/config → /config, …/metadata → /metadata |
the database — users, libraries, every listener's position in every book — and covers, per-item metadata, logs | yes — the same pull, the same snapshot, archive audiobookshelf-state (ADR-0050); required since §6.5 ran on 2026-09-29, so a missing directory fails the pull by name |
jellyfin-cache |
transcode scratch, image caches | no — regenerable |
${NAVIDROME_DATA_PATH} → /data |
Navidrome's database — users, playlists, favourites, play counts — and extracted artwork | yes — the same pull, the same snapshot, archive navidrome-data; required since §6.6 ran on 2026-09-30, so a missing directory fails the pull by name |
/cache (tmpfs) |
Navidrome's transcodes and resized artwork | no — regenerable, and gone on restart |
${MEDIA_PATH} → /media, ${AUDIOBOOKS_PATH} → /audiobooks, ${MUSIC_PATH} → /music |
the library itself | no — see below |
ADR-0008 already ruled the library replaceable and its loss "annoying rather than catastrophic", so backing up 18 TB of re-acquirable files would spend the mirror's capacity contradicting a decision already taken. But re-acquiring a series does not restore which episode anyone was on, and that part is measured in megabytes. The mirror buys availability; the backup buys the index.
How the yes works, and where it was not true. For the first week this
table said yes — scripts/backup-volumes.sh, and that script had no entry
for the volume, cannot see this host's Docker, and would have found the state
on erebor/ix-apps rather than on the dataset build-the-nas.md §4 calls
backed up (#484). ADR-0045 settles it: /config is a bind mount on
erebor/apps, TrueNAS snapshots that dataset nightly, and the monitoring host
pulls the newest snapshot's copy over the 99 → 40:22 pass — the rule
ADR-0016 wrote for exactly this and nothing else — encrypting it on arrival
with age and copying the set on to oracle in the same run. Navidrome's
/data is a second bind mount on the same dataset and a second archive in the
same set, read out of the same snapshot, so the two are always one instant
(#141). Neither server is ever stopped; the snapshot is the quiesce. Nothing on this host initiates
anything, which is the terminal property ADR-0016 keeps.
build-the-nas.md §6.2 turns the pull on and §6.3 restores from it.
What is not backed up, on purpose: erebor/media, the library;
jellyfin-cache, which scripts/backup-volumes.sh lists as disposable by
name; Navidrome's /cache, a tmpfs; and erebor/ix-apps, Docker's images and that cache volume. And
scripts/backup-volumes.sh does not run against this stack at all —
STACK=media reports nothing to archive, which is the true answer here.
The library is mounted read-only, into both servers. Jellyfin keeps
metadata in /config by default and Navidrome in its database, so neither has
any need to write there — and a media server on the segment
with the game consoles should not be able to delete the thing it serves.
Every other tier has a secrets/<stack>.example.yaml and a .sops.yaml rule
of its own. This one has neither, and #528 decided that rather than let it
happen: Jellyfin's admin is created by its own setup wizard and kept as a
hash in jellyfin.db inside jellyfin-config. No environment variable or
rendered file is a way to hand it in, so a value in a secrets file would
reach nothing — and make secrets-init STACK=media has to run on the stack's
host, which ships neither make nor sops. The plaintext is in the
operator's password manager, beside pfSense's and iLO's. docs/security.md
§ Secrets records the exception next to Home Assistant's, which is the same
class, and names what would retire it.
What that costs is one command: make render STACK=media dies on the missing
file, and that is expected. This stack is deployed by the two curl lines in
build-the-nas.md §6, never by make up.
Nobody needs the password to get back in. From a workstation on Hicks,
Forgot Password on http://10.0.40.30:8096 writes a PIN file into the
config volume; the login page then takes the PIN and sets a new password. The
file is read from a TrueNAS shell:
docker exec media-jellyfin sh -c 'cat /config/passwordreset*.json /config/data/passwordreset*.json 2>/dev/null'The flow only answers an address Jellyfin counts as local, which every RFC
1918 range is until its LAN networks setting says otherwise — and Hicks is
the segment the 50 → 40 passes were made for. Before the wizard has been run
at all there is no admin to reset, and /health reads Degraded, which is
why the healthcheck ignores the body.
Audiobookshelf's root is the same shape: created on its first-run
screen, kept as a bcrypt hash in absdatabase.sqlite, and in the password
manager beside Jellyfin's. Its way back in was measured on 2026-09-22 rather
than assumed. From the stack directory on smaug, with the service stopped,
clear the hash with the image's own node and sqlite3 module:
docker compose stop audiobookshelf \
&& docker compose run --rm --no-deps -T --entrypoint node audiobookshelf -e \
'const s=require("/app/node_modules/sqlite3");const d=new s.Database("/config/absdatabase.sqlite");d.run("UPDATE users SET pash=NULL WHERE type=\"root\"",function(e){console.log(e||("cleared "+this.changes));d.close()})' \
&& docker compose up -droot then signs in with a blank password, and the old one is refused;
set a new one under Settings → Users at once, because until then anyone
on Hicks or CasaBonita who types root is the administrator.
Navidrome is the same class, and joined this section on 2026-09-22
(#141). Its admin is created from the TrueNAS shell, not the web form —
docker exec -it media-navidrome navidrome user create --admin -u <name> -n,
which prompts for the password — immediately after the first up
(build-the-nas.md §6), because until an admin exists the first visitor to
:4533 is offered the form that creates one. Once one exists the form
answers 403. The hash is in navidrome.db on NAVIDROME_DATA_PATH, the
password in the password manager beside Jellyfin's. Its way back in without
the password is the same binary, from the same shell:
docker exec -it media-navidrome navidrome user edit -u <name> --set-password -nEvery non-obvious line in compose.yaml came off the pinned image, not off
upstream's documentation. Jellyfin's on 2026-09-16:
- It declares no user, so it would run as root unless told otherwise. Nothing else in this estate does, and neither does this.
/configand/cacheare mode777, which is what lets it run as65534with no chown sidecar — the dancestacks/sensitivedocuments for Caddy is not needed here.curlis present;wget,busyboxandncare not, which decides the healthcheck shape this repository otherwise writes with busybox wget./healthanswers200with a body readingDegradedon a fresh instance. The healthcheck therefore keys on the status code and ignores the body — requiringHealthywould mark the container unhealthy from first boot until somebody finished the setup wizard, which onrestart: unless-stoppedis a restart loop.- 65 MiB idle RSS, which is what the 2 GiB ceiling is a ceiling over.
Audiobookshelf's lines came off its pinned image on 2026-09-22, booted
read-only as 65534 with a generated two-minute book, a root user, a scan, a
progress write and a forced transcode. compose.yaml numbers all nine; the
ones that decide a line:
- It binds
PORT=80by default, which a process with no capabilities cannot — hencePORT=13378in the environment. /configand/metadatado not exist in the image. The server creates them, so the bind directories are created and chowned first; there is no image mode to lean on.wgetandbusyboxare present,curlis not — Jellyfin's opposite.- Its entrypoint is
tini, so the service setsinit: false, as the two Immich images do instacks/sensitive. - The database is in rollback-journal mode, not WAL, and progress is its
mediaProgressestable. Its own auto-backups are off by default and stay off. - 91 MiB idle, 119 MiB transcoding, under a 1 GiB ceiling.
Navidrome's on 2026-09-22, from a scratch boot as 65534, read-only and
cap-dropped, with a generated track in the library:
- It declares no user either, and its
/dataand/musicare root755rather than777./datais a bind mount created65534-owned;/musicis only read. - The image has no
/cache, and a named volume there kills it. Docker makes the mount point root755, Navidrome cannot create/cache/plugins, and the process aborts a second after logging that it is ready. The cache is a tmpfs owned by65534instead — and it is off/dataon purpose, so the backup does not carry transcodes. ND_PLUGINS_ENABLED=falsestops it creating a0700/data/plugins, the one directory it wrote that the backup user could not be assumed to read./pinganswers200before any admin exists, so the healthcheck has no bootstrap problem of the kind Jellyfin's/healthhas.wget,curl,busyboxandncare all present; the healthcheck useswget.- An admin made with
navidrome user create --adminin the running container closes the web form:/auth/createAdminanswered403afterwards, and the login took the password. - 49 MiB idle RSS after the first scan, which is what the 1 GiB ceiling — covering the 384 MiB tmpfs as well — is a ceiling over.
ADR-0040 keeps the media stack in this repository on the strength of Quick
Sync working, and names its own reopen condition: the iGPU reaching a
container. The CPU half is confirmed — Active Video: IGD on an E3-1225 v6,
read off the machine — but a live P630 and a P630 a container can use are
different claims. devices: /dev/dri and RENDER_GID are where the second
claim is made; build-the-nas.md §6 is where it gets tested, before the
library exists, because moving a populated library is a weekend.
On 2026-09-19 both halves passed. renderD128 is listed inside the
container as root 107, id there reads groups=65534(nogroup),107, and
a 1080p clip played at a forced 480p was decoded with -hwaccel vaapi on
the iHD driver, scaled by scale_vaapi and encoded by h264_qsv at about
five times real time — read off the ffmpeg command line in
/config/log/FFmpeg.Transcode-*.log, not off the dashboard. ADR-0040's
reopen condition is closed; the stack stays here.