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.
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:
- Create an NFS PersistentVolume pointing at the Unraid backups share
- Build a CronJob that copies the Ghost SQLite DB and content directories
- Bundle everything into a timestamped tarball on the NFS mount
- 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/backupsReadWriteMany 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: 10GiThe 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-backupsA 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
|| truebecause 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:
- Flux redeploys Ghost from Git: the deployment, service, ingress, and backup CronJob all come back automatically
- Ghost creates a fresh empty PVC
- Copy the latest backup from Unraid:
tar xzf ghost_YYYYMMDD_HHMMSS.tar.gz -C /path/to/ghost-content/ - 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
| Data | Backup Method | Location | Retention | Protects Against |
|---|---|---|---|---|
| All PVCs | Longhorn snapshots | Same NVMe | 7 daily + 4 weekly | Accidental deletion, app corruption |
| Miniflux DB | pg_dump CronJob | Longhorn PVC (same NVMe) | 7 daily | Database corruption |
| Ghost content | cp + tar CronJob | Unraid NFS (separate machine) | 7 daily | Drive failure, node loss |
| Cluster config | Git repo | GitHub | Full history | Everything 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.