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 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 .241 is 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):

  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:
  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.

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 & 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: /config on a local named volume; /audiobooks and /downloads on the two NFS volumes shown in Step 2; image lscr.io/linuxserver/lazylibrarian version-pinned (Dependabot bumps it); host port 5299.
  • Deploy: Portainer git stack lazylibrarian, compose path docker-pve1/apps/lazylibrarian/docker-compose.yml, branch main, relative-path volumes, PUID/PGID/TZ as stack env vars.
  • The loop: LazyLibrarian grabs via the existing Prowlarr indexers + qBittorrent/NZBGet, lands audiobooks in /audiobooks (= Holly's Media/Music/Audiobooks), and Audiobookshelf serves them — replacing Audible.

See the stack's own README.md for the click-by-click Portainer registration.