Self-Hosting Audiobookshelf
A GitOps Build in Three Stages w/FluxCD

I added Audiobookshelf to the homelab this week, and keeping true to GitOps the entire deployment lives in Git. No manual kubectl apply, no clicking around a dashboard. If it is running on my cluster there is a YAML file somewhere that says so. This is true for all my self hosted apps.
The rule I try to hold myself to: kubectl is for troubleshooting and ad hoc testing only. Flux is the one making sure the cluster matches what is committed to the repo, no matter what I or anyone else deploys by hand.
Here is how the process went; including the mistakes and troubleshooting.
Stage One: Getting It Running
First step was the basic scaffolding. I created a directory for Audiobookshelf under apps/base, then built four manifests: a namespace, a deployment, a service, and a config map.
The namespace and service were simple:
apiVersion: v1
kind: Namespace
metadata:
name: audiobookshelf
apiVersion: v1
kind: Service
metadata:
name: audiobookshelf
spec:
ports:
- port: 3005
selector:
app: audiobookshelf
type: ClusterIP
The config map took a bit more digging. I checked Audiobookshelf's documentation for the environment variables it accepts. PORT is all I need for now, but HOST and NODE_ENV could be used later.
apiVersion: v1
kind: ConfigMap
metadata:
name: audiobookshelf-configmap
namespace: audiobookshelf
data:
PORT: "3005"
apiVersion: apps/v1
kind: Deployment
metadata:
name: audiobookshelf
spec:
replicas: 1
selector:
matchLabels:
app: audiobookshelf
template:
metadata:
labels:
app: audiobookshelf
spec:
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:v2.35.1
ports:
- containerPort: 3005
envFrom:
- configMapRef:
name: audiobookshelf-configmap
A kustomization file tied the four manifests together, and a matching entry under apps/staging wired it into Flux.
To test, I port forwarded locally from "Larry" (my ssh jump box):
kubectl port-forward deployment/audiobookshelf 3005 3005
and from my laptop:
ssh -N -L 3005:localhost:3005 larry-local
Getting there took more commits than I would like to admit. A typo in the image name. A typo in the config map. A case-sensitivity mistake in the namespace. A missing kustomization entry. Every one of them was a small obvious fix once Flux told me the deployment was not converging, which is exactly the point of running things this way. Nothing silently broke. It just sat there refusing to apply until the YAML was correct. see my commit history
Stage Two: Persistent Storage
With the app running, next up was making sure data survived a pod restart. I added a PersistentVolumeClaim and mounted it into the container using subPaths to separate the /config, /metadata, and /audiobooks directories inside one shared volume.
It worked. I killed the pod, it came back, and the data was still there. The only mistake in this stage was forgetting to add the new storage manifest to the kustomization file, which meant the PVC never actually got applied until I caught it a few minutes later.
The single-volume approach looked fine at this point. It would not stay that way.🥀️🥺️
Stage Three: Running as Non-Root and Going Public
This stage did two things: it locked the container down, and it put Audiobookshelf on the internet.
For the security piece, I set the deployment to run as the node user instead of root with privilege escalation disabled. This is where the subPath approach from Stage Two fell apart. I set the security context to UID and GID 1000, expecting that to line up cleanly with the mounted directories.
Instead I kept seeing GID 0 on the mounted paths, and the fix was not a permissions tweak. It was simply mounting three separate PVCs for /config, /metadata, and /audiobooks instead of subPaths on one shared volume.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: audiobookshelf-config
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: audiobookshelf-metadata
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: audiobookshelf-audiobooks
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
apiVersion: apps/v1
kind: Deployment
metadata:
name: audiobookshelf
spec:
replicas: 1
selector:
matchLabels:
app: audiobookshelf
template:
metadata:
labels:
app: audiobookshelf
spec:
securityContext:
fsGroup: 1000
runAsUser: 1000
runAsGroup: 1000
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:v2.35.1
ports:
- containerPort: 3005
envFrom:
- configMapRef:
name: audiobookshelf-configmap
securityContext:
allowPrivilegeEscalation: false
volumeMounts:
- mountPath: /config
name: audiobookshelf-config
- mountPath: /metadata
name: audiobookshelf-metadata
- mountPath: /audiobooks
name: audiobookshelf-audiobooks
restartPolicy: Always
volumes:
- name: audiobookshelf-config
persistentVolumeClaim:
claimName: audiobookshelf-config
- name: audiobookshelf-metadata
persistentVolumeClaim:
claimName: audiobookshelf-metadata
- name: audiobookshelf-audiobooks
persistentVolumeClaim:
claimName: audiobookshelf-audiobooks
With that sorted; the last piece was exposing it outside the cluster. I went for a Cloudflare tunnel, and the workflow followed the same pattern I use everywhere else in this repo:
create the credential
wrap it in a Kubernetes secret
encrypt the secret before it ever touches Git.
1.
cloudflared tunnel create audiobooks
cd ~/.cloudflared
2.
kubectl create secret generic tunnel-credentials \
--from-file=credentials.json=<CREDS>.json \
--dry-run=client -o yaml > cloudflare-secret.yaml
3.
sops --age=$AGE_PUBLIC \
--encrypt --encrypted-regex '^(data|stringData)$' --in-place cloudflare-secret.yaml
Then in the Cloudflare console add a DNS A-record pointing at the tunnel.
Finally I created a cloudflared deployment, and a config map describing the ingress rule:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
spec:
selector:
matchLabels:
app: cloudflared
replicas: 2
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:2026.7.2
args:
- tunnel
# Points cloudflared to the config file, which configures what
# cloudflared will actually do. This file is created by a ConfigMap
# below.
- --config
- /etc/cloudflared/config/config.yaml
- run
livenessProbe:
httpGet:
# Cloudflared has a /ready endpoint which returns 200 if and only if
# it has an active connection to the edge.
path: /ready
port: 2000
failureThreshold: 1
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: config
mountPath: /etc/cloudflared/config
readOnly: true
# Each tunnel has an associated "credentials file" which authorizes machines
# to run the tunnel. cloudflared will read this file from its local filesystem,
# and it'll be stored in a k8s secret.
- name: creds
mountPath: /etc/cloudflared/creds
readOnly: true
volumes:
- name: creds
secret:
secretName: tunnel-credentials
# Create a config.yaml file from the ConfigMap below.
- name: config
configMap:
name: cloudflared
items:
- key: config.yaml
path: config.yaml
---
# This ConfigMap is just a way to define the cloudflared config.yaml file in k8s.
# It's useful to define it in k8s, rather than as a stand-alone .yaml file, because
# this lets you use various k8s templating solutions (e.g. Helm charts) to
# parameterize your config, instead of just using string literals.
apiVersion: v1
kind: ConfigMap
metadata:
name: cloudflared
data:
config.yaml: |
# Name of the tunnel you want to run
tunnel: abs
credentials-file: /etc/cloudflared/creds/credentials.json
# Serves the metrics server under /metrics and the readiness server under /ready
metrics: 0.0.0.0:2000
no-autoupdate: true
ingress:
- hostname: abs.angelchavez.net
service: http://audiobookshelf:3005
# This rule sends traffic to the built-in hello-world HTTP server. This can help debug connectivity
# issues. If hello.example.com resolves and tunnel.example.com does not, then the problem is
# in the connection from cloudflared to your local service, not from the internet to cloudflared.
- hostname: hello.example.com
service: hello_world
# This rule matches any traffic which didn't match a previous rule, and responds with HTTP 404.
- service: http_status:404
Commit - push and Flux handled the rest. Audiobookshelf-Staging is live at abs.angelchavez.net now.
One thing worth noting for anyone doing the same as me: Cloudflare tunnels cap uploads at 1GiB, which is worth knowing before trying to upload a large audiobook library through the tunnel instead of loading it onto the volume directly/locally.
Where This Leaves Things
3 stages, a dozen or so small fixes, and the app now runs as an unprivileged user; keeps its data across restarts and is reachable from outside the house. All without a single manual kubectl apply making it into the running state.
Repo: github.com/angel-n-chavez/homelab
Next time: I'm going to walk through deploying my Production environment which will be running the exact same apps as staging, but will be used by family and friends.



