Automated Ghost Backups to Offsite NFS on Unraid

Setting up a nightly CronJob to back up Ghost's SQLite database and content to an offsite NFS share on Unraid, because Longhorn replicas on a single node are not real backups.

Automated Ghost Backups to Offsite NFS on Unraid

My Ghost blog runs on a single-node Kubernetes cluster: one Dell laptop, one NVMe, one copy of everything. Longhorn handles persistent storage, but with a single node, “replicas” is a polite fiction. If that NVMe dies, so does every post I’ve written. Time to fix that.

The Problem

Ghost uses SQLite as its database. The entire blog (posts, settings, members, themes) lives in a single file at /var/lib/ghost/content/data/ghost.db. Longhorn snapshots exist, but on a single-node cluster they’re stored on the same NVMe as the live data. That’s not a backup, that’s a second copy of the same single point of failure.

I needed backups that leave the machine entirely. My Unraid NAS has a dedicated backup share on a separate disk array. That’s the target.

The Solution

A Kubernetes CronJob that runs nightly, copies the SQLite database safely, bundles it with uploaded images and themes, and writes the archive to an NFS mount pointing at Unraid.

NFS PersistentVolume

Static NFS PVs are the simplest way to mount an external share into a K8s pod. No CSI driver, no provisioner, just a PV that points at an NFS export and a PVC that binds to it by name.

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-backups
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  nfs:
    server: 192.168.1.133
    path: /mnt/user/backups

The PVC in the ghost namespace binds to it with volumeName: nfs-backups and an empty storageClassName. No dynamic provisioning, no ambiguity about which PV it gets.

The CronJob

apiVersion: batch/v1
kind: CronJob
metadata:
  name: ghost-backup
  namespace: ghost
spec:
  schedule: "30 2 * * *"
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            fsGroup: 1000
            seccompProfile:
              type: RuntimeDefault
          containers:
            - name: backup
              image: alpine:3.21
              securityContext:
                allowPrivilegeEscalation: false
                capabilities:
                  drop: ["ALL"]
              command:
                - /bin/sh
                - -c
                - |
                  set -e
                  BACKUP_DIR="/backups/ghost"
                  mkdir -p "${BACKUP_DIR}"
                  TIMESTAMP=$(date +%Y%m%d_%H%M%S)
                  BACKUP_FILE="${BACKUP_DIR}/ghost_${TIMESTAMP}.tar.gz"
                  # Copy SQLite DB + WAL/SHM for consistent snapshot
                  mkdir -p /tmp/dbcopy
                  cp /content/data/ghost.db /tmp/dbcopy/
                  cp /content/data/ghost.db-wal /tmp/dbcopy/ 2>/dev/null || true
                  cp /content/data/ghost.db-shm /tmp/dbcopy/ 2>/dev/null || true
                  # Bundle DB + content directories
                  tar czf "${BACKUP_FILE}" -C /tmp/dbcopy . \
                    -C /content images themes settings 2>/dev/null || \
                  tar czf "${BACKUP_FILE}" -C /tmp/dbcopy . \
                    -C /content images themes 2>/dev/null || \
                  tar czf "${BACKUP_FILE}" -C /tmp/dbcopy .
                  # Retain last 7 backups
                  ls -t ${BACKUP_DIR}/ghost_*.tar.gz | tail -n +8 | xargs -r rm -v
              volumeMounts:
                - name: content
                  mountPath: /content
                  readOnly: true
                - name: backups
                  mountPath: /backups
          volumes:
            - name: content
              persistentVolumeClaim:
                claimName: ghost-content
            - name: backups
              persistentVolumeClaim:
                claimName: ghost-backups

A few things worth calling out:

SQLite WAL handling. Ghost runs SQLite in WAL (Write-Ahead Log) mode. The database is actually three files: ghost.db, ghost.db-wal, and ghost.db-shm. Copying just the main .db file without the WAL can give you a stale or corrupt backup. The script copies all three to /tmp/dbcopy/ before archiving. The WAL and SHM files may not always exist (they’re cleaned up during checkpoints), hence the || true.

Content volume mounted read-only. The backup job mounts Ghost’s content PVC as readOnly: true. The CronJob has no business writing to the live content directory. It only reads.

Graceful fallback on tar. Not every Ghost install will have all directories (images, themes, settings). The script tries the full set first, falls back to just DB + images + themes, and finally falls back to just the database. A fresh install with no uploads still gets backed up.

7-day retention. ls -t | tail -n +8 | xargs rm keeps the 7 most recent archives and prunes the rest. Simple, predictable, no external tooling.

Security hardened. Non-root UID, all capabilities dropped, seccomp enabled, no privilege escalation. The backup pod can read Ghost’s content and write to the NFS share. Nothing else.

Network Policies

The backup pod needs egress to exactly two places: DNS (Kubernetes requires it for pod startup) and the NFS server on port 2049. The Ghost namespace’s network policies include a CIDR-based egress rule for the NAS on ports 2049/TCP, 111/TCP, and 111/UDP (portmapper).

GitOps

Both backup-pvc.yaml and backup-cronjob.yaml are listed in the Ghost app’s kustomization.yaml. Push to main, Flux reconciles, backup starts running the next night. No manual kubectl apply, no drift.

What I Learned

Longhorn on a single node is not backup. Longhorn is excellent storage infrastructure, but replicas on one node are redundant copies on the same disk. I had three replicas configured initially (the Longhorn default) before realizing they were all scheduling to the same node. Dropped to 1 replica and set up real offsite backup instead.

SQLite backups need WAL awareness. Early versions of this script only copied ghost.db. The backup looked fine, until I tried restoring it and found it was missing recent posts. The WAL file had uncommitted transactions that never made it into the main database file. Always copy the WAL.

Static NFS PVs are the right tool here. I briefly considered an NFS CSI provisioner, but for a handful of static mounts to a single NAS, that’s over-engineering. A static PV with volumeName binding is four lines of YAML and zero moving parts. I use the same pattern for my music library (Navidrome) and audiobooks (Audiobookshelf).

The same NFS share works for multiple apps. Ghost backs up to /backups/ghost/, Life Hub backs up to /backups/life-hub/. Both use separate PVs pointing to the same NFS export. The subdirectory convention keeps them isolated without needing separate shares on Unraid.

Verification

After deploying, I triggered a manual run to verify:

kubectl create job --from=cronjob/ghost-backup ghost-backup-test -n ghost
kubectl logs -n ghost job/ghost-backup-test

The output confirmed the archive was written to the NFS share. I also checked Unraid to verify the file existed on disk and extracted it to confirm the SQLite database was intact and contained current posts.

The CronJob has been running nightly without issues. Each backup is about 5 MB (mostly the SQLite database), and the 7-day rotation keeps disk usage minimal. If the NVMe ever dies, I’m one tar xzf away from a full restore.