| 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.
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: rack 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#
kubectl get deployments -A
kubectl get statefulsets -A
kubectl get pvc -A -o wide
kubectl get storageclassYou 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#
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.
Two controllers will undo a manual scale if you let them. Flux reconciles the apps back to their manifests, so suspend their HelmReleases for the duration of the restore:
flux suspend helmrelease jellyfin qbittorrent sonarr -n default
flux suspend helmrelease grafana loki kube-prometheus-stack -n observabilityAnd prometheus-operator owns the Prometheus and Alertmanager StatefulSets — scaling those directly gets reverted within seconds. Set the replicas on their custom resources instead:
kubectl scale deployment jellyfin qbittorrent sonarr --replicas=0 -n default
kubectl scale deployment grafana --replicas=0 -n observability
kubectl scale statefulset loki --replicas=0 -n observabilitykubectl get prometheus,alertmanager -n observability # the resource names
kubectl patch prometheus <name> -n observability --type merge -p '{"spec":{"replicas":0}}'
kubectl patch alertmanager <name> -n observability --type merge -p '{"spec":{"replicas":0}}'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:
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 observabilityStep 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.
Steps 5 and 6 are also a button: on the restored volume, Create PV/PVC with Use Previous PVC ticked recreates both under the names recorded in the backup. The manifests below are what it does, and what you write when the UI isn’t an option.
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:
| |
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-pvThe PVC name has to be the original one the app asks for, not jellyfin-restored.
Step 7 — Check the binding, then scale up#
kubectl get pvc -n default | grep -E "(jellyfin|qbittorrent|sonarr)"
kubectl get pvc -n observability | grep -E "(grafana|storage-loki|prometheus)"
kubectl get volumes.longhorn.io -n longhorn-system | grep restoredEvery 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:
Resuming the HelmReleases scales the apps back to what their manifests say, and restores the Prometheus and Alertmanager replica counts with them:
flux resume helmrelease jellyfin qbittorrent sonarr -n default
flux resume helmrelease grafana loki kube-prometheus-stack -n observabilitykubectl get pods -A | grep -v Running | grep -v Completed
kubectl get pods -n default -o wide
kubectl get pods -n observability -o wideWithout the UI#
The same restore as a CRD, if the UI is down or you’re scripting it:
apiVersion: longhorn.io/v1beta2
kind: Volume
metadata:
name: jellyfin-restored
namespace: longhorn-system
spec:
size: "10737418240"
numberOfReplicas: 3
frontend: blockdev
fromBackup: "s3://longhorn@us-east-1/?backup=backup-<id>&volume=jellyfin"The fromBackup URL is the backup target plus the backup and volume names; the Longhorn UI shows it on the backup’s detail page.
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.