Restoring from Longhorn Backups

#kubernetes#storage

How to restore Kubernetes applications from Longhorn backups — scale down, remove empty PVCs, restore volumes, create PVs/PVCs, and bring everything back up.

I redeployed my K8s cluster with Flux and got back a perfectly healthy cluster with nothing in it. Every app running, every volume empty — no configs, no dashboards, no media metadata. This is what it took to get six applications back from Longhorn backups.

Application Size Namespace
Prometheus 45GB observability
Loki 20GB observability
Grafana 10GB observability
Jellyfin 10GB default
qBittorrent 5GB default
Sonarr 5GB default

About 30 minutes end to end.

You’ll need: kubectl access, Longhorn installed and connected to your S3 backend, and the Longhorn UI reachable.

Note (Updated June 2026)

Two things have changed since. The backup set is smaller — only the media and *arr config volumes are backed up now, opted in by PVC label, while Prometheus, Loki and Grafana deliberately start empty on a restore. And the whole flow below is task longhorn:restore in the infrastructure repo. The manual procedure still holds for restoring any single volume by hand. Current shape: homelab tour — Backup.

The backend also moved from MinIO to Garage in October 2025, after MinIO dropped their Docker images. Nothing below changes — Longhorn only ever sees S3. That migration is its own post.

Step 1 — Look before you touch

Terminal window
kubectl get deployments -A
kubectl get statefulsets -A
kubectl get pvc -A -o wide
kubectl get storageclass

You want the exact PVC names before you delete any of them. StatefulSet PVCs in particular are generated, not authored, and prometheus-kube-prometheus-stack-db-prometheus-kube-prometheus-stack-0 is not a name you’ll reconstruct from memory.


Step 2 — Scale everything down

Caution

Every workload touching a volume must be at zero replicas before you go near storage. A pod still writing to a PVC you’re about to delete is how a restore turns into a data loss incident.

Terminal window
kubectl scale deployment jellyfin --replicas=0 -n default
kubectl scale deployment qbittorrent --replicas=0 -n default
kubectl scale deployment sonarr --replicas=0 -n default
kubectl scale deployment grafana --replicas=0 -n observability
kubectl scale statefulset loki --replicas=0 -n observability
kubectl scale statefulset prometheus-kube-prometheus-stack --replicas=0 -n observability
kubectl scale statefulset alertmanager-kube-prometheus-stack --replicas=0 -n observability

Wait for the pods to actually terminate. --replicas=0 returns immediately; the pods do not.


Step 3 — Delete the empty PVCs

They hold nothing, and they’re what’s standing between the restored volumes and the names the apps expect:

Terminal window
kubectl delete pvc jellyfin -n default
kubectl delete pvc qbittorrent -n default
kubectl delete pvc sonarr -n default
kubectl delete pvc grafana -n observability
kubectl delete pvc storage-loki-0 -n observability
kubectl delete pvc prometheus-kube-prometheus-stack-db-prometheus-kube-prometheus-stack-0 -n observability

Step 4 — Restore the volumes

Longhorn UI → Backup tab → the ⟲ restore button on each backup:

  • Name: <app>-restored, e.g. jellyfin-restored
  • Storage Class: longhorn
  • Access Mode: ReadWriteOnce

The name matters — it becomes the volumeHandle in the next step, and there’s no rename afterwards.

Warning

Let every restore finish before continuing. A 45GB Prometheus volume takes a while, and a PV pointing at a half-restored volume binds cleanly and then fails at attach time with an error about replicas.


Step 5 — A PV per restored volume

Adjust storage and volumeHandle for each app:

# Repeat for each application
apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-restored-pv
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: longhorn
csi:
driver: driver.longhorn.io
fsType: ext4
volumeAttributes:
numberOfReplicas: "3"
staleReplicaTimeout: "30"
volumeHandle: jellyfin-restored

persistentVolumeReclaimPolicy: Retain is deliberate. On Delete, removing the PVC again during the same session takes the restored data with it.


Step 6 — A PVC bound to it by name

volumeName is what stops the scheduler from helpfully provisioning a fresh empty volume instead:

# Repeat for each application
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin
namespace: default
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: longhorn
volumeName: jellyfin-restored-pv

The PVC name has to be the original one the app asks for, not jellyfin-restored.


Step 7 — Check the binding, then scale up

Terminal window
kubectl get pvc -n default | grep -E "(jellyfin|qbittorrent|sonarr)"
kubectl get pvc -n observability | grep -E "(grafana|storage-loki|prometheus)"
kubectl get volumes -n longhorn-system | grep "restored"

Every PVC Bound, every Longhorn volume attached and healthy. Anything still Pending is a name mismatch between the PVC and the PV, and scaling up on top of it will only bury the error under a CrashLoopBackOff:

Terminal window
kubectl scale deployment jellyfin --replicas=1 -n default
kubectl scale deployment qbittorrent --replicas=1 -n default
kubectl scale deployment sonarr --replicas=1 -n default
kubectl scale deployment grafana --replicas=1 -n observability
kubectl scale statefulset loki --replicas=1 -n observability
kubectl scale statefulset prometheus-kube-prometheus-stack --replicas=1 -n observability
kubectl scale statefulset alertmanager-kube-prometheus-stack --replicas=1 -n observability
Terminal window
kubectl get pods -A | grep -v Running | grep -v Completed
kubectl get pods -n default -o wide
kubectl get pods -n observability -o wide

Without the UI

The same restore as a CRD, if the UI is down or you’re scripting it:

apiVersion: longhorn.io/v1beta1
kind: Volume
metadata:
name: jellyfin-restored
namespace: longhorn-system
spec:
size: "10737418240"
restoreVolumeRecurringJob: false
fromBackup: "s3://your-minio-bucket/backups/backup-name"

The seven steps above are what the automation replaced. Writing them out by hand once is still worth it — when the automated restore fails, this is the layer you drop back to, and half an hour of kubectl beats reconstructing the order under pressure.