Self-Hosting Media: Navidrome, Audiobookshelf, and the NFS Bridge to Unraid
Music and audiobooks served from Kubernetes, backed by NFS mounts to an Unraid NAS. Here's the architecture for keeping media libraries on spinning rust while running apps on NVMe.
When I migrated my homelab from Docker Compose to Kubernetes, the media apps were the ones that made me think hardest about storage. Every other service (Ghost, Miniflux, Life Hub) stores a database measured in megabytes. Navidrome and Audiobookshelf serve media libraries measured in hundreds of gigabytes.
Putting 500 GB of FLAC files on NVMe-backed Longhorn volumes would be wasteful, expensive, and pointless. Music files don't need millisecond I/O latency. They need reliable sequential reads at a modest throughput. That's exactly what a NAS with spinning disks does well.
The solution: NFS. The Kubernetes pods run on NVMe (fast, replicated, local). The media libraries live on Unraid (large, cheap, backed up). NFS bridges the two worlds.
The Architecture
The storage strategy follows a simple rule: NVMe for databases, NAS for media.
| Data Type | Storage | Why |
|---|---|---|
| App databases, configs | Longhorn (NVMe) | Fast random I/O, replicated across nodes |
| Music library (FLAC/MP3) | Unraid NFS | Sequential reads, 500+ GB, cheap storage |
| Audiobook library | Unraid NFS | Same: large files, sequential reads |
| Backups | Unraid NFS | Off-cluster, survives node failure |
Each app gets two volume mounts: a small Longhorn PVC for its database and configuration (fast, replicated) and a large NFS PV for the media library (read-only, shared).
NFS Persistent Volumes
The NFS setup uses static PersistentVolumes: no dynamic provisioner, no storage class. The PVs are defined once in the infrastructure layer and bound by name.
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-music
spec:
capacity:
storage: 500Gi
accessModes:
- ReadOnlyMany
nfs:
server: 192.168.1.133
path: /mnt/user/music
persistentVolumeReclaimPolicy: Retain
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-audiobooks
spec:
capacity:
storage: 500Gi
accessModes:
- ReadOnlyMany
nfs:
server: 192.168.1.133
path: /mnt/user/audiobooks
persistentVolumeReclaimPolicy: RetainKey decisions:
- ReadOnlyMany: Both PVs are mounted read-only. The Kubernetes pods consume media but never modify it. New music gets added to the NAS directly, via Samba, SSH, or the Unraid UI. This prevents accidental corruption and means multiple pods can mount the same volume simultaneously.
- Static binding: PVCs reference the PV by
volumeName, not by storage class. This is explicit and predictable. No dynamic provisioner needed, no race conditions on binding. - Retain policy: If the PVC is deleted, the PV (and the NFS data) survives. Media libraries are managed outside Kubernetes.
Navidrome: Music Streaming
Navidrome is a self-hosted music server that implements the Subsonic API (for mobile apps like DSub and Symphonium) and has its own web UI for browser-based listening.
containers:
- name: navidrome
image: deluan/navidrome:0.53.3
ports:
- containerPort: 4533
env:
- name: ND_MUSICFOLDER
value: /music
- name: ND_DATAFOLDER
value: /data
- name: ND_REVERSEPROXYUSERHEADER
value: X-authentik-username
- name: ND_ENABLETRANSCODINGCONFIG
value: "true"
volumeMounts:
- name: data
mountPath: /data # Longhorn PVC: database + config
- name: music
mountPath: /music # NFS PV: FLAC/MP3 library
readOnly: trueThe two-volume pattern in action: /data is a 5 GB Longhorn PVC that holds the SQLite database (artist metadata, playlists, user preferences, scrobble history). /music is the 500 GB NFS mount pointing at the Unraid share.
Authentication via ForwardAuth: The ND_REVERSEPROXYUSERHEADER setting enables auto-creation of Navidrome users from the Authentik proxy header. When a user authenticates through Authentik SSO, Traefik's ForwardAuth middleware passes the username, and Navidrome creates a local account automatically. No separate login required.
Transcoding: Navidrome can transcode FLAC to MP3/Opus on the fly for mobile clients on limited bandwidth. This is CPU-bound work that happens in the pod, not on the NAS. The NAS just serves the original files.
The Subsonic API compatibility is what makes this work for mobile. Apps like DSub (Android) and Symphonium connect to Navidrome's Subsonic endpoint through Cloudflare Tunnel, with Authentik handling authentication. The music never touches a third-party server.
Audiobookshelf: Audiobook Library
Audiobookshelf serves audiobooks and podcasts with per-user progress tracking, chapter navigation, and playback speed control. The mobile app syncs position across devices.
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:2.19.4
ports:
- containerPort: 80
volumeMounts:
- name: config
mountPath: /config # Longhorn PVC: database + user data
- name: metadata
mountPath: /metadata # emptyDir: ephemeral, recomputed per pod
- name: audiobooks
mountPath: /audiobooks # NFS PV: audiobook library
readOnly: trueSame two-volume pattern, plus a third: /metadata is an emptyDir volume for cached cover art and metadata that Audiobookshelf generates on startup. It's ephemeral by design: if the pod restarts, Audiobookshelf rescans and regenerates it. No point in persisting computed data.
The readOnly: true mount on the audiobook library is particularly important here. Audiobookshelf has built-in library management features (rename, move, delete) that could modify source files. Read-only NFS prevents this at the storage layer, regardless of app-level permissions. The NAS is the source of truth; Kubernetes pods are consumers.
Security Hardening
Both deployments follow the same security baseline:
securityContext:
runAsNonRoot: true
runAsUser: 1000 # or 33 for Navidrome (www-data)
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false # Both apps need to write temp filesNon-root execution, dropped capabilities, no privilege escalation. The readOnlyRootFilesystem is the one compromise: both apps write temporary files during transcoding or metadata processing. A future improvement would be mapping those temp paths to emptyDir volumes and enabling read-only root.
Network policies restrict egress to DNS only. Neither app needs to reach the internet: they serve content from local NFS mounts to local clients through the reverse proxy.
How It Feeds the Data Pipeline
Both Navidrome and Audiobookshelf have REST APIs that Life Hub's collector pipeline taps into for activity tracking.
The Navidrome collector authenticates via JWT and fetches the 50 most recently played songs. The Audiobookshelf collector uses a static bearer token and pulls listening sessions with timestamps, duration, and position data. Both push their records to Life Hub's ingest API every 15 minutes.
Life Hub reaches these services via cluster-internal DNS:
NAVIDROME_URL=http://navidrome.navidrome.svc.cluster.local:80
AUDIOBOOKSHELF_URL=http://audiobookshelf.audiobookshelf.svc.cluster.local:80No Cloudflare Tunnel, no Authentik, no TLS overhead. Service-to-service communication stays inside the cluster. The collectors see the same API as the mobile apps, just without the authentication middleware.
Why Not Just Run Plex?
Plex is the obvious comparison. It does music, audiobooks, movies, TV, and photos in one app. Three reasons I chose separate, purpose-built tools:
- No cloud dependency. Plex routes authentication through their servers. If Plex's auth service goes down (and it has), you can't access your own media. Navidrome and Audiobookshelf authenticate locally.
- Subsonic API ecosystem. The Subsonic protocol has decades of client app support. DSub, Symphonium, Ultrasonic, play:Sub. The app choice is enormous. Plex locks you into their client ecosystem.
- Resource efficiency. Navidrome uses ~50 MB of RAM at idle. Audiobookshelf uses ~100 MB. Plex's media scanner alone uses more than both combined. On a homelab with constrained resources, this matters.
The NFS Trade-offs
NFS isn't perfect. Here's what I've learned running it:
Startup latency. When Navidrome starts, it scans the music library. Over NFS, this takes longer than local storage: about 2-3 minutes for a full scan versus 30 seconds on local NVMe. But it only happens on pod restart, not during normal operation.
No file locking. NFS v3 (what Unraid serves by default) has limited file locking. This is fine for read-only media mounts but would be dangerous for databases. Never put SQLite on NFS. The Longhorn PVCs handle all database workloads.
Network dependency. If the NAS goes offline, the NFS mounts hang. Kubernetes will mark the pods as unhealthy, but they won't crash cleanly. They'll hang on I/O wait. Adding NFS mount options like soft,timeo=30 would improve this, but I haven't hit it in practice.
No encryption in transit. NFS v3 sends data unencrypted over the LAN. For a trusted home network serving music files, this is an acceptable trade-off. If this were sensitive data or a shared network, NFS v4 with Kerberos or a WireGuard tunnel would be necessary.
Lessons Learned
Separate storage by access pattern. Databases need fast random I/O. Media needs cheap sequential reads. Putting them on the same storage class optimizes for neither. The NVMe/NFS split costs nothing extra and matches each workload to its ideal storage.
Read-only mounts are worth the constraint. Mounting media read-only prevents an entire class of accidents. If an app has a bug that deletes files, or if I misconfigure a library path, the worst case is a pod crash, not data loss.
Static PVs are fine at homelab scale. Dynamic provisioning with a CSI driver is great for clusters with hundreds of volumes. For a homelab with 3-4 NFS shares, static PVs are simpler, more explicit, and easier to debug.
Cluster-internal service discovery eliminates auth overhead. Life Hub's collectors talk to Navidrome and Audiobookshelf over internal DNS. No TLS termination, no SSO middleware, no Cloudflare Tunnel. The security boundary is the namespace network policy, not the application authentication layer.
The whole setup serves my music and audiobook library to any device in my house (and remotely via Cloudflare Tunnel), feeds listening data into my personal analytics dashboard, and runs on hardware I already owned. The NFS bridge to Unraid is what makes it work: it lets Kubernetes do what it's good at (orchestrating applications) while the NAS does what it's good at (serving large files cheaply).