Longhorn is a CNCF distributed block storage system for Kubernetes. It turns the local disks of your nodes into replicated persistent volumes: each volume keeps copies on several nodes, so a pod can be rescheduled to another node and keep its data after a node failure. In this tutorial you will prepare Ubuntu 24.04 nodes, install Longhorn with Helm, create volumes with a custom StorageClass, expand one online and configure scheduled backups to S3-compatible object storage.

Prerequisites

To follow this tutorial, you will need:

  • A Kubernetes cluster (1.25 or newer) with at least three worker nodes running Ubuntu 24.04, for example CubePath VPS instances. Longhorn recommends 4 vCPUs and 4 GB of RAM per node for production; 2 vCPUs and 4 GB work for testing.
  • Free disk space on each node under /var/lib/longhorn, the default data path. Every replica of a volume uses space on a different node.
  • SSH access with a sudo user to every node, and kubectl plus Helm 3 on the machine you manage the cluster from.
  • For Step 6: an S3-compatible bucket and an access key with read and write permissions on it.

Step 1 - Preparing the nodes

Longhorn attaches volumes to pods over iSCSI, so every node needs the iSCSI initiator. The nfs-common package is only needed for ReadWriteMany volumes, but installing it now avoids surprises later. Run these commands on every node:

sudo apt update
sudo apt install -y open-iscsi nfs-common
sudo systemctl enable --now iscsid
sudo modprobe iscsi_tcp

Make the kernel module load at boot:

echo iscsi_tcp | sudo tee /etc/modules-load.d/iscsi_tcp.conf

Verify the daemon is running:

systemctl is-active iscsid
active

Ubuntu Server enables multipathd, which claims the block devices Longhorn creates and makes volumes fail to mount with already mounted or mount point busy. Unless you use multipath for other storage, tell it to ignore regular SCSI devices. Open the configuration file:

sudo nano /etc/multipath.conf

Add this block at the end of the file:

blacklist {
    devnode "^sd[a-z0-9]+"
}

Restart the service and confirm it reads the new configuration without errors:

sudo systemctl restart multipathd
systemctl status multipathd --no-pager

Step 2 - Installing Longhorn with Helm

From your workstation, add the Longhorn chart repository and install it into its own namespace:

helm repo add longhorn https://charts.longhorn.io
helm repo update
helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --create-namespace

To pin a version, list the available ones with helm search repo longhorn/longhorn --versions and add --version. Longhorn only supports upgrading one minor version at a time, so note which version you install.

The first start takes a few minutes while images are pulled on each node. Watch the pods until they are all Running:

kubectl -n longhorn-system get pods -w
NAME                                                READY   STATUS    RESTARTS   AGE
csi-attacher-5c4bfdcf59-7hkqz                       1/1     Running   0          3m
csi-provisioner-667796df57-wx9bq                    1/1     Running   0          3m
engine-image-ei-db6c2b6f-2lmfv                      1/1     Running   0          4m
instance-manager-4c1a8a0d7c7e3f3e2b1d9c8f6a5e4d3c   1/1     Running   0          4m
longhorn-csi-plugin-6jg8p                           3/3     Running   0          3m
longhorn-driver-deployer-7d8b8c9f4d-bz2kx           1/1     Running   0          5m
longhorn-manager-5vxrl                              2/2     Running   0          5m
longhorn-ui-6f5d8c7b9d-hq4tn                        1/1     Running   0          5m

Press Ctrl+C to stop watching. The chart also creates a StorageClass called longhorn that keeps three replicas per volume and is marked as the cluster default:

kubectl get storageclass
NAME                 PROVISIONER          RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
longhorn (default)   driver.longhorn.io   Delete          Immediate           true                   5m

If another StorageClass is already the default, remove its storageclass.kubernetes.io/is-default-class annotation so only one default remains.

Check that Longhorn found all nodes and their disks:

kubectl -n longhorn-system get nodes.longhorn.io
NAME     READY   ALLOWSCHEDULING   SCHEDULABLE   AGE
k8s-w1   True    true              True          5m
k8s-w2   True    true              True          5m
k8s-w3   True    true              True          5m

Step 3 - Opening the Longhorn UI

The web UI shows volumes, replicas, node disk usage and backups. It has no authentication of its own, so do not expose the longhorn-frontend Service publicly without an authenticating Ingress. For administration, a port-forward is enough:

kubectl -n longhorn-system port-forward svc/longhorn-frontend 8080:80

Open http://localhost:8080 in your browser. If you run kubectl on a remote server, forward the port over SSH first with ssh -L 8080:localhost:8080 your_user@your_server_ip.

Step 4 - Creating a custom StorageClass

The default class is a good general choice. For workloads that replicate data themselves, such as a database cluster, two replicas and a preference for keeping one replica on the same node as the pod reduce overhead. Create the file:

nano longhorn-2r.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: longhorn-2r
provisioner: driver.longhorn.io
allowVolumeExpansion: true
reclaimPolicy: Retain
volumeBindingMode: Immediate
parameters:
  numberOfReplicas: "2"
  staleReplicaTimeout: "2880"
  dataLocality: "best-effort"
  fsType: "ext4"

reclaimPolicy: Retain keeps the Longhorn volume when the claim is deleted, which protects against an accidental kubectl delete pvc. staleReplicaTimeout is in minutes. Apply it:

kubectl apply -f longhorn-2r.yaml

Step 5 - Creating and expanding a volume

Create a PersistentVolumeClaim and a pod that writes to it:

nano longhorn-test.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-test
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn-2r
  resources:
    requests:
      storage: 2Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: volume-test
spec:
  containers:
    - name: app
      image: busybox
      command: ["sh", "-c", "date >> /data/log.txt && sleep 3600"]
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: data-test
kubectl apply -f longhorn-test.yaml
kubectl get pvc data-test
NAME        STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
data-test   Bound    pvc-3f6a1c2e-8b1d-4d7a-9c4e-1a2b3c4d5e6f   2Gi        RWO            longhorn-2r    20s

Confirm the data landed on the volume:

kubectl exec volume-test -- cat /data/log.txt

The Longhorn volume object shows its state and robustness. healthy means all replicas are in sync:

kubectl -n longhorn-system get volumes.longhorn.io
NAME                                       DATA ENGINE   STATE      ROBUSTNESS   SCHEDULED   SIZE         NODE     AGE
pvc-3f6a1c2e-8b1d-4d7a-9c4e-1a2b3c4d5e6f   v1            attached   healthy                  2147483648   k8s-w2   1m

Longhorn supports online expansion. Increase the request on the claim to 5 GiB:

kubectl patch pvc data-test -p '{"spec":{"resources":{"requests":{"storage":"5Gi"}}}}'

After a few seconds the filesystem inside the pod reflects the new size:

kubectl exec volume-test -- df -h /data
Filesystem                Size      Used Available Use% Mounted on
/dev/longhorn/pvc-3f6a1c2e-8b1d-4d7a-9c4e-1a2b3c4d5e6f
                          4.9G     24.0K      4.9G   0% /data

Volumes can only grow; you cannot shrink a PVC.

Step 6 - Configuring backups to S3

Snapshots live on the same nodes as the volume, so they do not protect you from losing the cluster. Backups copy volume data to an external backup target. Create a secret with the S3 credentials in the longhorn-system namespace. AWS_ENDPOINTS is only needed for S3-compatible providers other than AWS:

kubectl -n longhorn-system create secret generic longhorn-s3-secret \
  --from-literal=AWS_ACCESS_KEY_ID=your_access_key \
  --from-literal=AWS_SECRET_ACCESS_KEY=your_secret_key \
  --from-literal=AWS_ENDPOINTS=https://your_s3_endpoint

Point the default backup target at your bucket. The URL format is s3://bucket_name@region/optional_prefix/:

kubectl -n longhorn-system patch backuptargets.longhorn.io default --type merge \
  -p '{"spec":{"backupTargetURL":"s3://your_bucket@us-east-1/longhorn/","credentialSecret":"longhorn-s3-secret"}}'

You can do the same from the UI under Settings > Backup Target. Verify that Longhorn can reach the bucket:

kubectl -n longhorn-system get backuptargets.longhorn.io
NAME      URL                                 CREDENTIAL           LASTBACKUPAT   AVAILABLE   LASTSYNCEDAT
default   s3://your_bucket@us-east-1/longhorn/   longhorn-s3-secret   5m0s           true        2026-09-25T10:20:11Z

Now schedule a nightly backup with a RecurringJob. Jobs in the default group apply to every volume that has no other recurring job assigned:

nano backup-daily.yaml
apiVersion: longhorn.io/v1beta2
kind: RecurringJob
metadata:
  name: backup-daily
  namespace: longhorn-system
spec:
  task: backup
  cron: "0 2 * * *"
  groups:
    - default
  retain: 7
  concurrency: 2

This keeps the last seven daily backups of each volume and runs at most two backups at a time. Apply it and check that it is registered:

kubectl apply -f backup-daily.yaml
kubectl -n longhorn-system get recurringjobs.longhorn.io
NAME           GROUPS        TASK     CRON        RETAIN   CONCURRENCY   AGE   LABELS
backup-daily   ["default"]   backup   0 2 * * *   7        2             10s

To test the target without waiting for the schedule, open the volume in the UI and click Create Backup. The backup appears on the Backup page, and from there you can restore it to a new volume, even on a different cluster configured with the same backup target.

Troubleshooting

Volume stuck in attaching or the pod reports MountVolume.MountDevice failed. Check that iscsid is active on the node running the pod and that multipathd is not holding the device (Step 1). kubectl describe pod your_pod shows the exact error.

Volume shows degraded. One replica is missing, usually because a node is down or its disk is full. Longhorn rebuilds the replica automatically when a node with space is available. Check disk usage per node in the UI or with kubectl -n longhorn-system get nodes.longhorn.io -o wide.

Backup target shows AVAILABLE false. The credentials, endpoint or bucket name are wrong. Read the error in kubectl -n longhorn-system describe backuptargets.longhorn.io default.

Conclusion

You prepared Ubuntu 24.04 nodes for Longhorn, installed it with Helm, created replicated volumes through a custom StorageClass, grew a volume online and scheduled daily backups to S3. Next, you can add a snapshot recurring job for frequent local restore points, deploy the snapshot controller to use Kubernetes VolumeSnapshot objects with Longhorn, or scrape the longhorn-backend metrics endpoint with Prometheus to alert on degraded volumes.