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 LazyLibrarian (docker-pve1/apps/lazylibrarian/) — a self-hosted
audiobook/ebook downloader that feeds Holly's Media/Music/Audiobooks, which Audiobookshelf
already serves.
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 (Portainer/GitOps) gives it version-pinned, git-tracked, auto-redeploying config — Holly just serves bytes.
- One deploy model. Everything else on
.241is a Portainer git 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. LazyLibrarian]
end
subgraph Holly["Holly - Unraid (192.168.1.200)"]
M[/mnt/user/Media/]
D[/mnt/user/downloads/]
end
A -->|NFS: /audiobooks| M
A -->|NFS: /downloads| 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:
lazylibrarian:
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 |
LazyLibrarian (writes), Audiobookshelf (serves) |
| Download / import | :/mnt/user/downloads |
/downloads |
qBittorrent/NZBGet (write), LazyLibrarian (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 & licence note¶
Run the new stack on the existing CT 101 Portainer node — do not stand up a new Portainer environment for it. The homelab Portainer is on the Business Edition 3-node free tier; CT 101 is already an enrolled node, so adding a stack there costs nothing and stays within the node budget. (Homepage on CT 113 and Jarvis on VM 121 are the other two enrolled environments.)
Worked example: LazyLibrarian¶
Everything above is realised in docker-pve1/apps/lazylibrarian/:
- Compose:
/configon a local named volume;/audiobooksand/downloadson the two NFS volumes shown in Step 2; imagelscr.io/linuxserver/lazylibrarianversion-pinned (Dependabot bumps it); host port 5299. - Deploy: Portainer git stack
lazylibrarian, compose pathdocker-pve1/apps/lazylibrarian/docker-compose.yml, branchmain, relative-path volumes,PUID/PGID/TZas stack env vars. - The loop: LazyLibrarian grabs via the existing Prowlarr indexers + qBittorrent/NZBGet, lands
audiobooks in
/audiobooks(= Holly'sMedia/Music/Audiobooks), and Audiobookshelf serves them — replacing Audible.
See the stack's own README.md
for the click-by-click Portainer registration.