Skip to content

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/cifs volume fails with EACCES.
  • Holly's NFS server does not serve external clientsnfsd answers 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 .241 is 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):

  1. NFS Security Settings → Export = Yes
  2. Security = Private
  3. Rule = the LAN /22 only:

    192.168.0.0/22(sec=sys,rw,no_subtree_check)
    

Apply the same rule to:

  • downloads — the new scoped export (import/staging dir shared with qBittorrent/NZBGet).
  • Mediatighten the existing world-open export down to that /22 rule.

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.

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 downloads is 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 at abr.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 + the beets-audible plugin) — the piece that mounts Holly. /audiobooks and /downloads are 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 runs beet import /downloads/audiobooks and files each book tidily into /audiobooks/$Author/$Series/$Title (= Holly's Media/Music/Audiobooks), which Audiobookshelf serves — replacing Audible.
  • Deploy: Arcane git-sync stack audiobookrequest (projectName audiobookrequest), compose path stacks/apps/audiobookrequest/docker-compose.yml, branch main, 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.