# Audiobookshelf (rootless Podman + Quadlet) Runs [Audiobookshelf](https://www.audiobookshelf.org/) on `emily` as a **rootless Podman** container managed by a **systemd user unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md). Replaces the plain `docker run` container on `monoco`: ```command docker run -d \ --volume /var/lib/audiobookshelf/config:/config \ --volume /var/lib/audiobookshelf/metadata:/metadata \ --volume /var/lib/audiobookshelf/audiobooks:/audiobooks \ --volume /var/lib/audiobookshelf/podcasts:/podcasts \ --name=audiobookshelf \ --env=TZ=Asia/Kolkata \ --restart=unless-stopped \ ghcr.io/advplyr/audiobookshelf:latest ``` Unlike [bitwarden/](../bitwarden/readme.md) and [tandoor/](../tandoor/readme.md), **there is no database to attach.** Audiobookshelf's state is one SQLite file plus files under `~/podman-data/audiobookshelf`, so this play has no `postgres` role and nothing to restore from the shared cluster. Two facts about the published image shape everything below, and both were verified against the real image rather than read off the docs: | Finding | Consequence | |---|---| | The image declares **no `VOLUME`** | The four `Volume=` lines in the unit are load-bearing, not decorative. Without them the app runs perfectly — and loses everything on the next recreate | | The image ships **no `HEALTHCHECK`** | This play defines one (`HealthCmd=`) so it can gate on the app actually answering | ## Ordering Run [caddy/playbook.yml](../caddy/playbook.yml) first. It is the reverse proxy, owns the Caddyfile this site appends to, supplies the `caddy.service` handlers, and is the one play that applies the rootless-Podman prerequisites (installing podman, `loginctl enable-linger`, the low-port sysctl, the Quadlet directory). There is no second prerequisite to wait for — no cluster to bring across, since this app has no database. This playbook does **not** move the library. Copy the data first (see ["Migrating the data"](#migrating-the-data-from-monoco)) so the first start finds the real `config/`, rather than creating a fresh empty database next to it. ## Layout ``` audiobookshelf/ ├── playbook.yml ├── vars/default.yml └── roles/ ├── audiobookshelf/ # the four data dirs, the Quadlet unit, start + verify └── caddy/ # appends this site's block to the shared Caddyfile ``` Quadlet units and state: | On the host | What | |---|---| | `~/.config/containers/systemd/audiobookshelf.container` | the generated unit (Ansible-templated) | | `~/.config/containers/systemd/audiobookshelf.network` | the app network, written by `caddy/roles/register` | | `~/.config/containers/systemd/caddy.container.d/50-audiobookshelf.conf` | attaches Caddy to that network | | `~/podman-data/audiobookshelf/config/` | `absdatabase.sqlite`, `migrations/` | | `~/podman-data/audiobookshelf/metadata/` | `backups/`, `cache/`, `logs/`, `streams/`, cover art, item metadata | | `~/podman-data/audiobookshelf/audiobooks/` | the book library (15 GB) | | `~/podman-data/audiobookshelf/podcasts/` | downloaded podcast episodes | ## What is state, and what is not Per [docs/backup-plan.md §2.1](../docs/backup-plan.md): | Path | Keep? | Why | |---|---|---| | `config/` | **required** | `absdatabase.sqlite` — users, libraries, listening progress, bookmarks. 2.6 MB, irreplaceable | | `metadata/` | **required** | cover art, item metadata, and `backups/`. 19 MB, irreplaceable | | `audiobooks/` | media | 15 GB across 696 files. Re-downloadable, and [proposed for exclusion](../docs/backup-plan.md) from the backup ([§8](../docs/backup-plan.md)) | | `podcasts/` | media | episodes the app downloads here. Unlike `audiobooks/`, this directory is **written to at runtime** | So the real state is **22 MB, not 15 GB**. Treating the library as state is what made a "full backup" of this service look impossible ([docs/backup-plan.md §2](../docs/backup-plan.md)). `metadata/cache/`, `metadata/logs/` and `metadata/streams/` are regenerable — transcode scratch space, the log, and the image cache. They live under `metadata/` because that is where the app puts them, and they are small; the migration copies them anyway rather than carving out exceptions. **A live `absdatabase.sqlite` must not be `tar`-ed.** It is SQLite in WAL mode, so the database file and its `-wal` sidecar are copied at different instants and the result can be torn — a backup that will not open ([docs/backup-plan.md §3.1](../docs/backup-plan.md)). Use the app's own **Settings → Backups** (which writes into `BACKUP_PATH`, defaulting to `metadata/backups`), or stop the container and copy the directory. ## How it registers with Caddy Caddy is the base unit and knows nothing about its upstreams. This playbook calls the shared include in [caddy/roles/register](../caddy/roles/register/tasks/main.yaml), which writes the network unit **and** the drop-in that pulls it into Caddy: ```yaml - name: Register Audiobookshelf's network with Caddy ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" ``` It runs in `pre_tasks`, **before** the app unit is started, because `audiobookshelf.container` references `audiobookshelf.network`: Quadlet fails *unit generation* — so `systemctl --user start` reports "unit not found" — when a referenced `.network` file does not exist yet. `network_name` is `audiobookshelf`, so the *file* is `audiobookshelf.network` and the *Podman network* is `audiobookshelf_net` (`NetworkName=` in the network unit pins it, matching the name the migration plan already assumed). [roles/caddy](roles/caddy/tasks/main.yaml) appends the site block to the shared Caddyfile with `blockinfile`, exactly as the other services do. Caddy handles the WebSocket upgrade the app's live-socket features need, and imposes no request-body limit, so there is nothing like the old nginx `client_max_body_size` to configure. **Nothing is published on the host.** The container listens on 80 and Caddy reaches it as `audiobookshelf:80` over `audiobookshelf_net`, so this play needs none of the port-floor work Caddy itself needs. ## Settings | Variable | Value / purpose | |---|---| | `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand | | `podman_data_dir` | `~/podman-data` | | `quadlet_dir` | `~/.config/containers/systemd` | | `host` | `audiobooks.tanshu.com` — the site block Caddy serves | | `container_name` | `audiobookshelf`, and the Quadlet unit / container name | | `image` | `ghcr.io/advplyr/audiobookshelf:latest` — already fully qualified | | `network_name` | `audiobookshelf` → `audiobookshelf.network` / `audiobookshelf_net` | | `config_dir` / `metadata_dir` | `~/podman-data/audiobookshelf/{config,metadata}` → `/config`, `/metadata` | | `audiobooks_dir` / `podcasts_dir` | `~/podman-data/audiobookshelf/{audiobooks,podcasts}` → `/audiobooks`, `/podcasts` | | `container_port` | `80` — the image's `EXPOSE 80` / `ENV PORT=80` | | `timezone` | `Asia/Kolkata`, the only setting the Docker-era container passed | | `health_cmd` | The `HealthCmd=` the unit defines, since the image has none | | `caddy_name` / `caddyfile_path` | The shared Caddy unit and its Caddyfile | The three `podman_*` / `quadlet_dir` vars are **duplicated** from [caddy/vars/default.yml](../caddy/vars/default.yml), as in every service playbook. That is a deliberate trade: a play cannot include the Caddy vars file wholesale, because it also sets `host_directory` (to the *Caddy* data directory) and every service defines that for itself. Keep them in sync. Caddy's fourth, `podman_unprivileged_port_start`, belongs to the podman tasks this play does not run. Like Caddy, `podman_user` is derived from the connection and the play carries no `become`. Because the podman tasks are not run here, there is **no guard against connecting as `root`**; the prerequisites, including that check, live in `caddy/playbook.yml`. `UserNS=keep-id:uid=0,gid=0` — the image has no `USER` instruction, so it runs as container-root throughout and writes `/config` and `/metadata` as root ([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). Mapping container root onto `podman_user` keeps `~/podman-data/audiobookshelf` owned by `tanshu` and leaves no subuid-owned files to trip over backups. Upstream explicitly does not support `PUID`/`PGID`, so this is the mechanism for the ownership rather than an env var. **There is no environment file here**, unlike `bitwarden/` and `tandoor/`. This deployment holds no secrets: Audiobookshelf generates its own JWT signing key inside `config/`, and `TZ` is not sensitive — so it goes on the unit as a plain `Environment=` line, and the unit stays world-readable without exposing anything. ## Running ```command ansible-playbook -u tanshu audiobookshelf/playbook.yml ``` The play targets `emily` directly, so the inventory must contain it — the one in `prepare-server/` does, and its `ansible.cfg` names it. This play applies **only** this app's units and data. Its `pre_tasks` run Caddy's `register` include and nothing else. The rootless-Podman prerequisites are the shared task file at [caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml) and are **not** run here. A host that has already run `caddy/playbook.yml` has them. `systemctl --user enable audiobookshelf` is neither needed nor possible: the unit is *generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. ## Migrating the data from monoco Everything lives in `/var/lib/audiobookshelf` today, owned by **uid 0** (the image runs as root). It moves to `~/podman-data/audiobookshelf`, and the whole tree is ~15 GB — budget real time for the bulk copy ([docs/podman-migration.md §4.3](../docs/podman-migration.md)). **Run all of this from the control node that holds both SSH aliases** — the machine where `ssh monoco` and `ssh emily` both work. The two hosts cannot reach each other here: `emily` is a fresh instance that knows nothing about `monoco`, and the `monoco` alias exists only in the control node's SSH config. `rsync` also cannot copy remote-to-remote, so the 15 GB is staged on the control node in between — check it has the room before starting. ### 1. Look before you copy ```bash # Sizes, numeric owners (`ls -ln`), and whether the tree is readable without # sudo. The old container ran as root, so the files are uid 0. ssh monoco 'du -sh /var/lib/audiobookshelf/*' ssh monoco 'ls -ln /var/lib/audiobookshelf' ssh monoco 'find /var/lib/audiobookshelf -type f ! -readable' # empty = no sudo needed # Space on the destination (it needs the full 15 GB) and on the control node # (it holds a second copy until step 6 deletes it). ssh emily 'df -h / | tail -1' df -h /home | tail -1 ``` If the `find` above prints anything, add `--rsync-path="sudo rsync"` to the `monoco:` side of every `rsync` below so the transfer reads as root. ### 2. Create the destinations ```bash ssh emily 'mkdir -p ~/podman-data/audiobookshelf/{config,metadata,audiobooks,podcasts}' mkdir -p ~/audiobookshelf-migrate/{config,metadata,audiobooks,podcasts} ``` ### 3. The 15 GB library, while the old container is still serving Audiobookshelf only *reads* `/audiobooks`, so the slow part needs no downtime. If a hop drops, run it again — `rsync -P` resumes at the file it was on. ```bash # monoco -> control node rsync -aP --no-owner --no-group \ monoco:/var/lib/audiobookshelf/audiobooks/ ~/audiobookshelf-migrate/audiobooks/ # control node -> emily rsync -aP --no-owner --no-group \ ~/audiobookshelf-migrate/audiobooks/ emily:~/podman-data/audiobookshelf/audiobooks/ ``` A second run of either command that prints **no filenames** means that hop is complete — cheaper and stronger than comparing counts, and it is why `rsync` is used here rather than a single `tar` stream. Do **not** add `--delete`: if the playbook has already run on emily, `--delete` would remove files the app created there that are not on monoco. ### 4. The 22 MB of state, with the old container stopped ```bash # Not optional for config/: absdatabase.sqlite is live SQLite in WAL mode, and a # hot copy can be torn (docs/backup-plan.md §3.1). podcasts/ is written at # runtime too, so it is not safe to copy while the app is up either. ssh monoco 'docker stop audiobookshelf' for d in config metadata podcasts; do rsync -aP --no-owner --no-group "monoco:/var/lib/audiobookshelf/$d/" ~/audiobookshelf-migrate/"$d"/ rsync -aP --no-owner --no-group ~/audiobookshelf-migrate/"$d"/ "emily:~/podman-data/audiobookshelf/$d/" done # Final incremental pass over the library, in case a scan wrote metadata beside # a book during step 3. rsync -aP --no-owner --no-group monoco:/var/lib/audiobookshelf/audiobooks/ ~/audiobookshelf-migrate/audiobooks/ rsync -aP --no-owner --no-group ~/audiobookshelf-migrate/audiobooks/ emily:~/podman-data/audiobookshelf/audiobooks/ ``` `--no-owner` / `--no-group` are explicit because `-a` implies the opposite, and the source files are **root**-owned on monoco. The container runs as uid 0 in-container, which `keep-id` maps to `tanshu`, so files that arrive owned by `tanshu` are already correct — and restoring root ownership would be wrong. ### 5. Verify, before the first start Verify *before* deploying: once the playbook has run, emily's `config/absdatabase.sqlite` has been migrated in place and no longer matches the source. ```bash # File count and total bytes per directory, both ends. audiobooks/ should be # 696 files and 15.0 GB on monoco (docs/backup-plan.md §2). for d in config metadata audiobooks podcasts; do echo "== $d ==" ssh monoco "find /var/lib/audiobookshelf/$d -type f | wc -l; du -sb /var/lib/audiobookshelf/$d | cut -f1" ssh emily "find ~/podman-data/audiobookshelf/$d -type f | wc -l; du -sb ~/podman-data/audiobookshelf/$d | cut -f1" done # Byte-level digests for the irreplaceable 22 MB. Hashing the 15 GB library costs # more than copying it did, so it is checked by count and size above instead. for d in config metadata; do a=$(ssh monoco "cd /var/lib/audiobookshelf/$d && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum") b=$(ssh emily "cd ~/podman-data/audiobookshelf/$d && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum") [ -n "$a" ] && [ "$a" = "$b" ] && echo "$d OK ${a%% *}" || { echo "$d MISMATCH: $a vs $b"; false; } done # Nothing may be owned by root or by a subuid. ssh emily 'find ~/podman-data/audiobookshelf ! -user tanshu' # empty = correct ``` ### 6. Deploy, then clean up ```bash # Run after the copy, so the first start finds the real database rather than # creating an empty one beside it. cd prepare-server && ansible-playbook -u tanshu ../audiobookshelf/playbook.yml # Only after step 5 passed and the play reported a healthy container. The # staging copy is ~15 GB; the library is on emily twice over until this runs. rm -rf ~/audiobookshelf-migrate ``` Stop and leave the old container stopped. Its `config/` is now a stale snapshot, and two live copies would diverge — rolling back means restoring monoco's `config/` and starting it there, not starting it in place. ### Alternatives Stream straight across with `tar` if the control node has no room for 15 GB. It cannot resume: a drop at 90% starts over. ```bash set -o pipefail ssh monoco 'tar -C /var/lib/audiobookshelf/audiobooks -cf - .' \ | ssh emily 'tar --no-same-owner -C ~/podman-data/audiobookshelf/audiobooks -xf -' ``` `set -o pipefail` matters: without it the pipeline reports only the *receiving* `tar`'s status, so a read error or a dropped stream on the source side exits 0 and leaves a silently short copy. The 22 MB of state is small enough that the same pipe is fine for it. Or stop the old container up front and copy all four directories in one pass. Simpler, and provably consistent because nothing is writing; the cost is that the service is down for the whole 15 GB instead of just the 22 MB. ```bash ssh monoco 'docker stop audiobookshelf' for d in config metadata audiobooks podcasts; do rsync -aP --no-owner --no-group "monoco:/var/lib/audiobookshelf/$d/" ~/audiobookshelf-migrate/"$d"/ rsync -aP --no-owner --no-group ~/audiobookshelf-migrate/"$d"/ "emily:~/podman-data/audiobookshelf/$d/" done ``` ## Verification ```bash systemctl --user status audiobookshelf journalctl --user -u audiobookshelf -f podman logs audiobookshelf # What the playbook checks for you: podman healthcheck run audiobookshelf # the HealthCmd= the unit defines podman exec audiobookshelf ls -d /config /metadata /audiobooks /podcasts test -e ~/podman-data/audiobookshelf/config/absdatabase.sqlite # The health the unit reports on its own 30s timer, not just once: podman inspect audiobookshelf --format '{{.State.Health.Status}}' # The mounts are real bind mounts and not the container layer: podman inspect audiobookshelf --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' # The site, end to end. curl -sSI https://audiobooks.tanshu.com/healthcheck # 200 OK, body "OK" ``` Then open `https://audiobooks.tanshu.com` **in a browser** and confirm the library is there and an existing book resumes at the right timestamp. A `curl` cannot check that the migrated database is *readable*; only the app can. On a *fresh* install the first visit asks you to create the admin account and add a library — the playbook creates the directories, not the library entries. Also exercise a **write** through the app — ask it to match/scan a book, or start a podcast download — and confirm the files it creates under `~/podman-data/audiobookshelf` are owned by `tanshu`. That is the check that catches a wrong `keep-id`; a read-only smoke test will not. Then reboot the host and confirm the unit returns unattended — without `loginctl enable-linger tanshu` it will not, and the failure is silent. ## Notes and gotchas - **The image declares no `VOLUME`.** This is the one silent failure worth knowing: with a missing `Volume=` line the app starts, serves, and looks healthy while writing into the container's writable layer — and loses it all on the next recreate. The play guards against it by asserting that `config/absdatabase.sqlite` exists on the *host* after the health check passes. - **The image ships no `HEALTHCHECK`, so this unit defines one.** Upstream's docs discourage health checks as "generally redundant to setting a restart policy" and as log noise; that is fair, and the reason for the deviation is that `is-active` cannot tell a serving server from a crash-looping one under `Restart=always`. There is deliberately **no `HealthOnFailure=`** — that would let a temporarily slow start kill and restart a healthy server. Add `HealthOnFailure=kill` yourself if you would rather have self-healing. - **`ROUTER_BASE_PATH` defaults to `/audiobookshelf`.** It is undocumented in the env reference but real, and the server logs it on every start. Bare paths still work — middleware rewrites non-prefixed URLs — which is why `reverse_proxy audiobookshelf:80` at the site root is correct. It matters only if you later serve the app under a subpath, where the prefix would appear twice. - **`wget`, not `curl`.** `curl` exists only in the image's builder stage and is absent at runtime; `wget` is present (BusyBox). That is what `health_cmd` uses. - **`latest` floats.** `:latest` is the newest stable release; `:edge` tracks every commit to master, and `vX.Y.Z` tags exist. Upstream makes no pinning recommendation either way. The image measured for this playbook was **2.37.1**, digest `sha256:581d68b2a6fc7ebf58d81c878a9f387cbbc0d88ac9d37b298b9cee10168af85b` — pin that digest in `vars/default.yml` if you want reproducible deploys. Upgrading is `podman pull` plus a restart; it may run SQLite migrations on first start. - **The 15 GB library is not backed up, and was never covered.** No backup script exists for this service at all ([docs/backup-plan.md §13.6](../docs/backup-plan.md)), and the media is a deliberate exclusion. Decide explicitly whether the audiobooks exist anywhere else ([§8](../docs/backup-plan.md)) — this playbook takes no position on it. - **Registering restarts all of Caddy.** Writing the network unit and the drop-in changes Caddy's generated unit, and Quadlet has no live equivalent of `docker network connect`, so Caddy is recreated — a brief restart of *every* hosted site, not just this one. See [caddy/readme.md](../caddy/readme.md), "Trade-off worth knowing". - **The scanner/watcher has no environment variable.** "Automatically watch libraries for changes" is a UI setting (`scannerDisableWatcher`, needs a restart), plus a per-library flag. It cannot be set from this playbook. - **Client IPs.** Whether the original client address survives the rootless port forward decides whether Audiobookshelf's own auth rate limiting (`RATE_LIMIT_AUTH_*`) sees real addresses. It is unverified on emily — see [caddy/readme.md](../caddy/readme.md), "Client IPs". - **If the Caddyfile does not exist yet**, this playbook creates it containing only the site block. That is a valid Caddyfile; the Caddy playbook's seeded global-options block is then not added, because its seed only fires on a missing or zero-length file. Deploy Caddy first on a fresh host. - **DNS.** `audiobooks.tanshu.com` must resolve to `emily` before Caddy can obtain a certificate for it. - **Root's crontab cannot `podman exec`** into another user's rootless containers, so any scheduled `Settings → Backups` orchestration or library scan belongs in a **systemd user timer** next to the Quadlet units. - **Do not restart the container on monoco.** Its `config/` is a stale snapshot of a database emily has moved past. ## Not done here - Pinning the image by digest or version tag, or wiring up `AutoUpdate=registry` with `podman auto-update`. - Backing up the 15 GB library, or deciding whether it belongs in scope at all. - Creating a backup script or systemd user timer for the 22 MB of real state — neither exists today, and the app's own **Settings → Backups** is the preferred route rather than anything Ansible-side. - Creating the admin account and library entries, which is a first-run UI step. - `HealthOnFailure=kill`, and any external monitoring or alerting on the `unhealthy` state.