Audiobookshelf (rootless Podman + Quadlet)
Runs Audiobookshelf on emily as a
rootless Podman container managed by a systemd user unit (Quadlet),
behind the shared rootless Caddy. Replaces the plain
docker run container on monoco:
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/ and tandoor/,
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 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") 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
| 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 from the backup (§8) |
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).
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). 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, which writes the network unit and the drop-in that pulls it into Caddy:
- 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 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, 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). 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
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 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).
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
# 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
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.
# 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
# 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.
# 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
# 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.
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.
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
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 missingVolume=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 thatconfig/absdatabase.sqliteexists 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 thatis-activecannot tell a serving server from a crash-looping one underRestart=always. There is deliberately noHealthOnFailure=— that would let a temporarily slow start kill and restart a healthy server. AddHealthOnFailure=killyourself if you would rather have self-healing. ROUTER_BASE_PATHdefaults 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 whyreverse_proxy audiobookshelf:80at the site root is correct. It matters only if you later serve the app under a subpath, where the prefix would appear twice.wget, notcurl.curlexists only in the image's builder stage and is absent at runtime;wgetis present (BusyBox). That is whathealth_cmduses.latestfloats.:latestis the newest stable release;:edgetracks every commit to master, andvX.Y.Ztags exist. Upstream makes no pinning recommendation either way. The image measured for this playbook was 2.37.1, digestsha256:581d68b2a6fc7ebf58d81c878a9f387cbbc0d88ac9d37b298b9cee10168af85b— pin that digest invars/default.ymlif you want reproducible deploys. Upgrading ispodman pullplus 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), and the media is a deliberate exclusion. Decide explicitly whether the audiobooks exist anywhere else (§8) — 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, "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, "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.commust resolve toemilybefore Caddy can obtain a certificate for it. - Root's crontab cannot
podman execinto another user's rootless containers, so any scheduledSettings → Backupsorchestration 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=registrywithpodman 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 theunhealthystate.