Backing Up Ghost to Unraid: Because Longhorn Snapshots Aren't Enough

Longhorn snapshots on a single-node cluster are not backups. Here is how I built a daily CronJob that backs up Ghost’s SQLite database and content to an NFS share on Unraid.

Backing Up Ghost to Unraid: Because Longhorn Snapshots Aren't Enough

My Ghost blog runs on a single-node Kubernetes cluster. The content (posts, images, themes, the SQLite database) all lives on a Longhorn PVC attached to one NVMe drive. Longhorn takes daily snapshots and keeps weekly ones too, but here’s the thing: those snapshots are on the same disk. If the NVMe dies, the snapshots die with it.

Everything else in the cluster is recoverable from Git. The manifests, secrets (SOPS-encrypted), network policies: all versioned and reconciled by Flux. But the blog content? The actual posts I’ve written, the images I’ve uploaded? Those exist only on that PVC. That’s the gap I needed to close.

The Approach

I already have an Unraid server on my LAN at 192.168.1.133 serving NFS shares for media (music, audiobooks). Adding a backups share was straightforward. The plan:

  1. Create an NFS PersistentVolume pointing at the Unraid backups share
  2. Build a CronJob that copies the Ghost SQLite DB and content directories
  3. Bundle everything into a timestamped tarball on the NFS mount
  4. Retain 7 days, auto-delete older backups

The NFS Infrastructure

The NFS PV is a static binding: no StorageClass, no dynamic provisioner. Just a manifest pointing at a server and path:

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

ReadWriteMany because multiple CronJobs across namespaces need to write to the same share. Retain reclaim policy so Kubernetes never deletes the PV data, even if the PVC is removed. The empty storageClassName sidesteps Longhorn entirely. The backup PVC goes straight to NFS, not through the CSI driver.

Each app binds to it with a namespaced PVC using volumeName:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ghost-backups
  namespace: ghost
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: ""
  volumeName: nfs-backups
  resources:
    requests:
      storage: 10Gi

The Backup CronJob

The CronJob runs daily at 2:30 AM, offset from the Miniflux PostgreSQL backup at 2:00 AM and Longhorn’s daily snapshot at 2:00 AM. That way I get both a Longhorn snapshot for fast local recovery and an NFS archive for disaster recovery on the same night, without stacking I/O on a node with 15.6 GB of RAM.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: ghost-backup
  namespace: ghost
spec:
  schedule: "30 2 * * *"
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        metadata:
          labels:
            app: ghost-backup
        spec:
          restartPolicy: OnFailure
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            runAsGroup: 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 (fallback chain for missing dirs)
                  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 .

                  echo "Backup complete: $(du -sh ${BACKUP_FILE})"

                  # Keep only last 7 backups
                  ls -t ${BACKUP_DIR}/ghost_*.tar.gz | \
                    tail -n +8 | xargs -r rm -v
              resources:
                requests:
                  cpu: 50m
                  memory: 64Mi
                limits:
                  cpu: 200m
                  memory: 256Mi
              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 details worth calling out:

  • The Ghost content PVC is mounted read-only. The backup job has no business writing to the live database.
  • The three-stage tar command handles content directories that may not exist yet: a fresh Ghost install with zero uploads falls back to just the database copy.
  • The WAL and SHM copies use || true because they may not exist if SQLite has already checkpointed.
  • The pod runs as UID 1000 with all capabilities dropped, privilege escalation blocked, and seccomp enforced. The backup job doesn’t need privileges.
  • No network policy changes needed: the CronJob only accesses local PVCs. NFS mounts are handled at the kernel level by the node’s NFS client, not through pod networking.

Why Not sqlite3 .backup?

The textbook approach is sqlite3 ghost.db ".backup backup.db", which uses the SQLite backup API and guarantees a consistent copy regardless of write activity. I tried it. The backup container runs as UID 1000 (runAsNonRoot: true), and installing sqlite3 via apk add requires root or a writable package cache. I wasn’t going to weaken the security context for a backup job.

I considered alternatives: building a custom image with sqlite3 baked in (maintenance burden I don’t want), or running a sidecar that shares the content volume (resource overhead for marginal gain). Neither was worth the complexity.

The pragmatic fix: copy all three WAL-mode files together. SQLite in WAL mode keeps the main .db file consistent at all times. The .db-wal file contains uncommitted transactions. When you open a database with a WAL file present, SQLite replays the log and reaches a consistent state. The theoretical risk of corruption during a concurrent write exists, but for a single-user blog backing up at 2:30 AM, it’s effectively zero. Good enough.

What Recovery Looks Like

If the NVMe dies and I need to rebuild:

  1. Flux redeploys Ghost from Git: the deployment, service, ingress, and backup CronJob all come back automatically
  2. Ghost creates a fresh empty PVC
  3. Copy the latest backup from Unraid: tar xzf ghost_YYYYMMDD_HHMMSS.tar.gz -C /path/to/ghost-content/
  4. Ghost restarts and picks up the restored database and content

Everything except the persistent data is in Git. The persistent data is on Unraid. The only data loss is whatever was written between the last backup and the failure: at most 24 hours of blog posts, which realistically means zero since I don’t write posts at 3 AM.

The archive is a self-contained, portable file that doesn’t depend on any Kubernetes tooling to restore. If the cluster is completely gone, I can still restore Ghost on any machine with tar and Node.js.

The Backup Landscape

DataBackup MethodLocationRetentionProtects Against
All PVCsLonghorn snapshotsSame NVMe7 daily + 4 weeklyAccidental deletion, app corruption
Miniflux DBpg_dump CronJobLonghorn PVC (same NVMe)7 dailyDatabase corruption
Ghost contentcp + tar CronJobUnraid NFS (separate machine)7 dailyDrive failure, node loss
Cluster configGit repoGitHubFull historyEverything except persistent data

Ghost is the first service with true offsite backup. The NFS infrastructure is reusable: adding Miniflux or Vaultwarden backups to Unraid is just another PVC binding to the same nfs-backups PV.

What I Learned

Longhorn snapshots are not backups. They’re point-in-time recovery on the same storage. If the disk fails, they’re gone. This is obvious when you say it out loud, but it’s easy to treat snapshot schedules as “we have backups” and move on.

NFS PVs are dirt simple and that’s the point. No CSI driver, no provisioner, no S3 credentials, no backup agents, no cloud costs. A static PV pointing at an NFS share gives every CronJob in the cluster a place to write backups. For a homelab backup target, boring is a feature.

Test your backups. I triggered a manual job with kubectl create job --from=cronjob/ghost-backup ghost-backup-test -n ghost and verified the tarball appeared on Unraid within seconds. The first run failed because apk add needed root. I wouldn’t have caught that waiting for the 2:30 AM schedule.

Make backups work from day one, not after you lose data. I set up Ghost, wrote a dozen blog posts, and only then thought about backups. If the NVMe had failed in that window, everything would have been gone. The CronJob took 30 minutes to build. The blog posts took weeks to write.

The backup tarballs are about 3 MB each. Seven days of retention uses maybe 25 MB on Unraid. The peace of mind is worth considerably more than that.