Moving an app off Holly into a container (runbook)¶
A repeatable pattern for taking a service that runs on the NAS host (Holly / Unraid,
192.168.1.200) and running it as a container on the Docker LXC (CT 101, 192.168.1.241)
instead, while it keeps reading and writing the media that stays on Holly over NFS.
The worked example is the AudioBookRequest + beets stack (stacks/apps/audiobookrequest/) —
its beets sidecar organizes finished audiobook downloads into Holly's Media/Music/Audiobooks,
which Audiobookshelf already serves. (It replaced LazyLibrarian, retired July 2026 as too heavy.)
Reality check (2026-07): NFS does not work here — this uses host CIFS + LXC bind
The NFS approach in this runbook (docker type: nfs volumes on CT 101) was the original
design but does not work and was never deployed. Two independent reasons:
- CT 101 is an unprivileged LXC and cannot mount any network filesystem itself — the
kernel denies the mount syscall in its user namespace, so a docker
type: nfs/cifsvolume fails withEACCES. - Holly's NFS server does not serve external clients —
nfsdanswers localhost (a self-mount is access-denied, i.e. it replies) but every mount from pve1/pve2 times out (TCP 2049 connects, the RPC never completes). This is an Unraid macvlan/br0 issue (Plex runs macvlan on.210); SMB is unaffected.
The working pattern is a host-side SMB/CIFS mount on pve1, bind-mounted into CT 101:
pve1 CIFS-mounts the Holly shares (as a dedicated Holly SMB user, pveshare) with
uid=101000,gid=101000 (CT 101's unprivileged idmap
maps host 101000 → container 1000, so files are owned by the app and writable), exposes
them via pct bind-mountpoints, and the compose uses bind volumes
(/mnt/nas/audiobooks, /mnt/nas/downloads). The exact host commands are in the AudioBookRequest
README.md.
The NFS-specific steps below are kept for the historical record and for a future NAS + client
that both support working NFS (e.g. post-TrueNAS, from a privileged guest).
Why¶
- Decouple the app from the NAS lifecycle. Holly is storage; keeping compute there means an
Unraid reboot or array pause takes the app down. Running the app on CT 101 (Arcane/GitOps)
gives it version-pinned, git-tracked, auto-redeploying config — Holly just serves bytes.
(Recent proof: the 2026-07-25 boot-USB failure corrupted Holly's
docker.img, and its Docker apps only came back after a VM reboot on 2026-07-29.) - One deploy model. Everything else on
.241is an Arcane git-sync stack; a new app should be too, not a hand-managed Unraid Docker template. - A template for the NAS rebuild. When Holly is later rebuilt (TrueNAS / a Proxmox host),
apps that only mount storage over NFS move with a one-line
addr=change — nothing to re-platform. This same recipe is the migration path.
flowchart LR
subgraph CT101["CT 101 - Docker LXC (192.168.1.241)"]
A[App container<br/>e.g. beets]
end
subgraph Holly["Holly - Unraid (192.168.1.200)"]
M[/mnt/user/Media/]
D[/mnt/user/downloads/]
end
A -->|"/audiobooks (SMB via pve1 bind)"| M
A -->|"/downloads (SMB via pve1 bind)"| D
Step 1 — Add a scoped NFS export on Unraid¶
Holly today NFS-exports only /mnt/user/Media, and that export is world-open. The app
also needs the downloads share, which is not NFS-exported yet. Fix both in the Unraid UI.
For each share (Main → Shares → the share, e.g. downloads, then Media):
- NFS Security Settings → Export =
Yes - Security =
Private -
Rule = the LAN
/22only:
Apply the same rule to:
downloads— the new scoped export (import/staging dir shared with qBittorrent/NZBGet).Media— tighten the existing world-open export down to that/22rule.
Rule anatomy
192.168.0.0/22 is the whole LAN (.1.x and .2.x are the same network — see the network
docs). sec=sys = standard AUTH_SYS (uid/gid) auth; rw = read-write; no_subtree_check
is the modern default that avoids subtree-check breakage on renames. No no_root_squash —
the container runs as PUID/PGID=1000, not root, so it doesn't need it.
Verify before you tighten Media
Anything currently mounting Media from outside 192.168.0.0/22 (a stray host, a
different subnet) will lose access the moment you narrow the rule. Confirm the LAN is really
/22 and that no off-subnet client depends on the old world-open export first.
Step 2 — Mount it in compose (local NFS volume driver)¶
Docker's built-in local volume driver can mount NFS directly — no fstab entry on the host,
the mount lives and dies with the volume. Declare one named volume per export:
volumes:
ll-audiobooks:
driver: local
driver_opts:
type: nfs
o: "addr=192.168.1.200,rw,nfsvers=4,hard,noatime"
device: ":/mnt/user/Media/Music/Audiobooks"
ll-downloads:
driver: local
driver_opts:
type: nfs
o: "addr=192.168.1.200,rw,nfsvers=4,hard,noatime"
device: ":/mnt/user/downloads"
Then attach them to the service:
services:
beets:
volumes:
- ll-config:/config # local named volume (app config/db)
- ll-audiobooks:/audiobooks
- ll-downloads:/downloads
addr=— Holly's IP (192.168.1.200); the one line that changes if the NAS is rebuilt.nfsvers=4— matches Unraid's NFSv4 export.hard— retry indefinitely if Holly blips rather than returning I/O errors mid-write.noatime— don't write an access timestamp on every read (less churn).device:— the exported path, leading colon included (:/mnt/user/...).
The export must exist first
A named NFS volume mounts lazily, on first container use. If the Unraid export from Step 1 isn't in place, the container fails to start with a mount error. Do Step 1 before the first deploy.
Step 3 — Path conventions¶
Keep two ideas separate: the library (final, curated files the media server reads) and the download/import dir (staging the download clients write, then the app imports from).
| Role | Holly export | Container mount | Who reads it |
|---|---|---|---|
| Library | :/mnt/user/Media/Music/Audiobooks |
/audiobooks |
beets (writes), Audiobookshelf (serves) |
| Download / import | :/mnt/user/downloads |
/downloads |
qBittorrent/NZBGet (write), beets (imports) |
| App config/db | (local named volume) | /config |
the app only |
Point the app's internal settings at the container paths (/audiobooks, /downloads),
never the host paths. Config/db stays on a local named volume on CT 101 — it's small, hot,
and shouldn't ride over NFS. Match the existing /downloads layout in
Downloads & folder layout so the clients and the app agree on where files land.
Step 4 — The hardlink caveat¶
On Unraid, a "share" is a fuse (shfs) overlay across disks. Importing a finished download from
downloads into Media crosses two different shfs shares, and a hardlink cannot span them —
so the "import" is a copy, not a hardlink (this is already true today, independent of NFS).
Consequences:
- Doubled disk use while both copies exist, and real write churn on every import (a 289 GB audiobook library is a lot of copying over time).
- The staging copy in
downloadsis not freed by the import — a torrent stays until it's done seeding and then cleaned up; a usenet grab needs the client (or the app) to remove it.
Mitigation: keep the library and the download/import dir on the same Unraid share where possible (e.g. an audiobooks download category under the same top-level share as the library), so import can atomic-move within one shfs share instead of copying across two. Where a single-share layout isn't practical, just budget for the copy and the extra space.
This is an Unraid property, not an NFS one
Mounting over NFS doesn't change it — the cross-share copy happens on Holly regardless of how the app reaches the files. A future single-pool NAS (TrueNAS/ZFS dataset) would let hardlinks work and remove the churn.
Step 5 — Host note¶
Run the new stack via Arcane on CT 101 — do not stand up a new docker host for it.
Arcane manages CT 101's docker as environment 0 (Local Docker) over the local socket; since
2026-08-10 it ALSO manages a second environment — the Jarvis VM agent (VM 121,
192.168.1.252:3553). CT 101 remains the host for the git-sync media stacks. (The same-host
headless agent retired 2026-07-21 is a different agent from the VM 121 one added 2026-08-10.)
Worked example: AudioBookRequest + beets¶
Everything above is realised in stacks/apps/audiobookrequest/ — live since 2026-07-21
on CT 101, the two-service stack that replaced LazyLibrarian (retired July 2026 as too heavy and
with an unreliable importer). Only the beets service touches Holly:
- AudioBookRequest (ABR) — the request/download UI on
192.168.1.241:8000, SSO-gated atabr.mdhmedia.uk. It needs no media mounts: it searches Audible for metadata and delegates grabs to the existing Prowlarr → qBittorrent/NZBGet, then triggers an Audiobookshelf scan. - beets (
lscr.io/linuxserver/beets+ thebeets-audibleplugin) — the piece that mounts Holly./audiobooksand/downloadsare bind volumes (/mnt/nas/audiobooks,/mnt/nas/downloads) over the pve1 host SMB/CIFS mounts of Holly's shares (see the reality check above — not the NFS volumes from Step 2; these are the exact binds LazyLibrarian used, kept when it was retired). A supervised import loop runsbeet import /downloads/audiobooksand files each book tidily into/audiobooks/$Author/$Series/$Title(= Holly'sMedia/Music/Audiobooks), which Audiobookshelf serves — replacing Audible. - Deploy: Arcane git-sync stack
audiobookrequest(projectNameaudiobookrequest), compose pathstacks/apps/audiobookrequest/docker-compose.yml, branchmain,syncDirectory: true,PUID/PGID/TZ+ the seeded ABR admin creds in the project.env.
See the stack's own README.md
for the click-by-click Arcane git-sync registration and the post-deploy ABR wiring.