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.
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/backupsThe 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-backupsA 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-testThe 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.