Skip to main content

Command Palette

Search for a command to run...

Self-Hosting Audiobookshelf

A GitOps Build in Three Stages w/FluxCD

Updated
7 min readView as Markdown
Self-Hosting Audiobookshelf
A
I am a cloud student, engineer, husband, father. Just documenting my journey as I learn cloud technologies. Want to connect or potentially hire?! connect with me on Linkedin

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:

  1. create the credential

  2. wrap it in a Kubernetes secret

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