The first deploy of the unit failed its own health gate in the worst possible shape: systemd said active (running), the engine logged a clean database init, and the server then listened on :80 under base path /audiobookshelf - so every probe of :8000 was refused and wait_for_health timed out at 180s beside a container that looked healthy. Cause: ABS_PORT / ABS_CONFIG_DIR / ABS_METADATA_DIR / ABS_PUID are the npm package's names, printed by the server README; this image never reads them. Its own contract, read off the pinned digest with podman inspect, is PORT=80 CONFIG_PATH=/config METADATA_PATH=/metadata, and the server greps for exactly PORT, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH. The two path variables appeared to work only because the image already exports the same values - the coincidence that made the inert lines look correct. The unit now speaks the image's names, adds ROUTER_BASE_PATH=/ so the service is the plain host:port the docs and the homepage card promise, and the dead ABS_* lines are deleted instead of kept as decoration. Proven against the same digest in a throwaway container (temp state dirs, loopback-only mapping, removed after): base path "/", listening on :8000, GET /healthcheck -> 200, and the old prefixed path -> 404. Guards: the quadlet seam now refuses an Environment=ABS_* line and requires PORT/ROUTER_BASE_PATH; the vault page gains the law with its failure signature and the two-second image-inspect check. Suite: ALL TESTS PASSED, 812 ok.
5.8 KiB
type, description, tags, timestamp
| type | description | tags | timestamp | |||
|---|---|---|---|---|---|---|
| reference | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, first-run account, laws. |
|
2026-11-02 |
Audiobookshelf
Audiobookshelf (ABS) serves the Entropy Volume audiobook library to browsers
and phones: it scans /mnt/entropy/Audiobooks, keeps its own SQLite library with
per-listener playback positions, and streams the audio. It is not a media
server — it shares nothing with Plex or Jellyfin and owns no files on the
array.
Host: Dankserver — Unit: audiobookshelf.service (rootless Quadlet, user
manager) — Internal-only: LAN address, no vhost, no DNS name.
Shape
| Item | Value |
|---|---|
| Unit file | dankserver/audiobookshelf/quadlet/audiobookshelf.container (the authority for every number here) |
| Image | ghcr.io/advplyr/audiobookshelf pinned by amd64 manifest digest — see Laws |
| Port | Environment=PORT=8000 + PublishPort=8000:8000 — web UI and API on one port |
| Base path | Environment=ROUTER_BASE_PATH=/ — the URL is the plain host:port, no /audiobookshelf prefix |
| Network | pasta (standalone; publishes nothing to other containers) |
| Audio bind | /mnt/entropy/Audiobooks:/audiobooks:ro — read-only, by design |
| State | /home/dank/Containers/audiobookshelf/config → /config, …/metadata → /metadata (both NVMe) |
| Mount gate | ExecStartPre=…/wait-for-mount.sh /mnt/entropy |
| Health | wget against /healthcheck inside the container, 30 s / 3 retries |
| I/O share | IOWeight=10 — a scan loses the array to every other tenant |
| Self-healing | Restart=always, RestartSec=5, WantedBy=default.target |
| Deploy | ./scripts/deploy.sh dankserver --service audiobookshelf |
| Handler | deploy_audiobookshelf in scripts/deploy-remote.sh |
First run
- Open
http://192.168.0.235:8000from the LAN. ABS accepts the initial account creation only from a local-network address; a remote address gets a refusal, not a form. - Create the owner account, then add one Audi Book library with folder
/audiobooks. The path is the in-container name of the read-only bind. - The first scan reads the whole tree once. It runs in the background — the
deploy gate is already green by then, and
IOWeight=10keeps it off the critical path.
Nothing in this repo holds an ABS credential: the account is created in the
browser and lives in the config bind on the SSD.
Laws
- Speak the image's env names, not the README's. The server reads
PORT,CONFIG_PATH,METADATA_PATHandROUTER_BASE_PATH; theABS_*family printed by the server's own README is the npm package's naming and is inert in this image. Measured 2026-11-02 on the first deploy: withABS_PORT=8000the server listened on:80under base path/audiobookshelf, sosystemctlsaidactive (running)while every probe of:8000was refused and the deploy gate failed at 180 s. Two seconds with the pinned digest settles it:podman inspect --format '{{.Config.Env}}' <image>. - The audio bind stays
:ro. ABS writes nothing that belongs beside the audio: its library, covers, progress and sidecar metadata all land in/metadataon the SSD. A writable bind would put stray writes on the spindle and hand the array to a service with no reason to hold it. If per-book.opf/.nfosidecars on disk are ever wanted, the rollback is one line — point the audio bind at the Media Metadata View the media servers already use — but that view's upper layer exists for those two servers' sidecars, so a third writer needs a reason first (Media Metadata View). - Do not bind the raw array without the mount gate. In a rootless unit
ConditionPathIsMountPoint=is fail-dead andRequiresMountsFor=is inert, so a missing array does not stop the start: podman invents an empty directory, ABS indexes it, and an empty library becomes recorded truth.wait-for-mount.shis the only gate that works here — same law the media servers obey (Quadlet Lifecycle). - The
HealthCmdmust usewget. The image is Alpine and shipswget, notcurl; acurlhealth command reads a perfectly healthy server as unhealthy andRestart=alwaysturns that into a loop. - The image pin is the amd64 manifest digest, not the index digest.
podman inspectreports the platform manifest (RepoDigests[0]) for what was pulled, which differs from the manifest-list digestlatestpoints at. Pin the one this host runs.latestis newer than the newest version tag: upstream stopped publishing version tags after2.19.0whilelatestkept moving, so "highest version tag" is stale here — read the index, don't guess from tags. IOWeight=10is inert without the host drop-ins.dankserver/host/systemd/carriesuser.slice.d/io-accounting.confand theuser@1000delegate, and the deployer installs both; without them systemd silently drops the weight (Quadlet Lifecycle).- No
systemctl --user enable. Generated Quadlet units cannot be enabled on this host;WantedBy=default.targetin the unit file is what carries the service across a reboot (Quadlet Lifecycle). - LAN-only until Frank says otherwise.
0.0.0.0:8000with no reverse proxy in front. Any public vhost requires the Anubis + Authentik chain first, and the outpost proxy provider has to exist before the vhost flips (external exposure policy inAGENTS.md).
Verification
./tests/run.sh covers this unit through tests/dankserver_quadlet.bats
(boot persistence, the SSD state roots, the :ro array bind, the mount gate, the
wget health check) and tests/media_overlay.bats (only Plex and
Jellyfin may reference the metadata view).