# Self-Hosting Audiobookshelf

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:

```yaml
apiVersion: v1
kind: Namespace
metadata:
  name: audiobookshelf
```

```yaml
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.

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: audiobookshelf-configmap
  namespace: audiobookshelf
data:
  PORT: "3005"
```

```yaml
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):

```plaintext
kubectl port-forward deployment/audiobookshelf 3005 3005
```

and from my laptop:

```plaintext
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](https://github.com/angel-n-chavez/homelab/commits/main/)

* * *

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

```yaml
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
```

```yaml
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.
    

```shell
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:

```yaml
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](http://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.
