A StorageClass tells Kubernetes how to create a volume when an application asks for one with a PersistentVolumeClaim (PVC), and a CSI (Container Storage Interface) driver is the plugin that actually talks to the storage backend. In this tutorial you will learn the StorageClass fields that matter, then build a working setup on self-managed infrastructure: an NFS server on Ubuntu 24.04, the official NFS CSI driver, and a StorageClass that provisions shared ReadWriteMany volumes on demand.

Prerequisites

To follow this guide you need:

  • A Kubernetes cluster (1.30 or newer) whose nodes run Ubuntu 24.04, and kubectl with cluster-admin rights.
  • Helm 3 on your workstation. Check with helm version.
  • One more Ubuntu 24.04 server for NFS, reachable from all cluster nodes over a private network, for example a CubePath VPS attached to the same private network as the cluster. This guide uses 10.0.0.10 for the NFS server and 10.0.0.0/24 for the private network; replace both with your own values.
  • A non-root user with sudo on the NFS server and on each node.

How StorageClasses and CSI drivers fit together

When a pod needs persistent storage, three objects are involved:

ObjectCreated byPurpose
PersistentVolumeClaim (PVC)The application owner"I need 10 GiB, mounted read-write by one or many pods"
StorageClassThe cluster administratorWhich CSI driver to use and with which parameters
PersistentVolume (PV)The CSI driver, automaticallyThe real volume, bound to the PVC

A StorageClass looks like this:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: example
provisioner: nfs.csi.k8s.io
parameters: {}
reclaimPolicy: Delete
volumeBindingMode: Immediate
allowVolumeExpansion: true
mountOptions: []

The fields you will set most often:

  • provisioner: the name of the CSI driver that creates volumes. Each driver documents its own name and its own parameters, which Kubernetes passes through without validating.
  • reclaimPolicy: what happens to the volume when the PVC is deleted. Delete (the default) removes the data; Retain keeps the PV and its data for manual recovery. The old Recycle policy is deprecated.
  • volumeBindingMode: Immediate creates the volume as soon as the PVC exists. WaitForFirstConsumer waits until a pod uses the PVC, so the volume is created where the pod is scheduled. Use it for node-local or zone-bound storage.
  • allowVolumeExpansion: lets users grow a PVC by editing spec.resources.requests.storage, if the driver supports it.

List the StorageClasses in your cluster to see what is already available:

kubectl get storageclass

If the list is empty, PVCs without a matching class stay Pending forever. The rest of this guide fixes that with NFS.

Choosing a CSI driver

On managed clouds a block storage driver is usually preinstalled. On self-managed VPS or bare metal clusters you install one yourself. The common options are:

DriverAccess modesGood for
NFS CSI driver (nfs.csi.k8s.io)RWO, RWXShared files, simple setup, an existing NFS server
Longhorn (driver.longhorn.io)RWO, RWXReplicated block storage using the nodes' local disks
Ceph CSI via Rook (RBD and CephFS)RWO, RWXLarge clusters with dedicated storage nodes
local-path-provisioner (rancher.io/local-path)RWOSingle-node or test clusters, data tied to one node

NFS is the simplest to operate and the only one here that needs no extra disks on the nodes, so it is what this tutorial builds. Keep in mind that NFS is a single server: it is fine for shared uploads, build caches and small apps, but databases are better served by block storage such as Longhorn or Ceph RBD.

Step 1 - Setting up the NFS server

On the NFS server, install the NFS server package:

sudo apt update
sudo apt install nfs-kernel-server

Create the directory that will hold all Kubernetes volumes. The CSI driver creates one subdirectory per PVC inside it:

sudo mkdir -p /srv/nfs/k8s

Open the exports file:

sudo nano /etc/exports

Add this line, replacing 10.0.0.0/24 with your private network:

/srv/nfs/k8s 10.0.0.0/24(rw,sync,no_subtree_check,no_root_squash)

no_root_squash is required because the driver creates and deletes the per-volume directories as root. It also means any root user on an allowed host has full control of the share, which is why the export must be limited to your private network and never to *.

Apply the exports and check them:

sudo exportfs -ra
sudo exportfs -v
/srv/nfs/k8s  	10.0.0.0/24(sync,wdelay,hide,no_subtree_check,sec=sys,rw,secure,no_root_squash,no_all_squash)

If UFW is active on the NFS server, allow NFSv4 (TCP port 2049) from the private network only:

sudo ufw allow from 10.0.0.0/24 to any port 2049 proto tcp

Step 2 - Preparing the Kubernetes nodes

Every node that may run a pod with an NFS volume needs the NFS client utilities. Run this on each node:

sudo apt update
sudo apt install nfs-common

From one node, confirm the export is visible and mountable:

sudo mount -t nfs4 10.0.0.10:/srv/nfs/k8s /mnt
df -h /mnt
sudo umount /mnt

df should show 10.0.0.10:/srv/nfs/k8s as the filesystem. If the mount hangs, the firewall or the network between the node and the server is blocking port 2049.

Step 3 - Installing the NFS CSI driver

The NFS CSI driver is maintained by the Kubernetes CSI project and published as a Helm chart. Add the repository:

helm repo add csi-driver-nfs https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts
helm repo update

Install it into kube-system:

helm install csi-driver-nfs csi-driver-nfs/csi-driver-nfs --namespace kube-system

The chart deploys a controller, which creates and deletes volumes, and a node DaemonSet, which mounts them on each node. Wait until they are running:

kubectl -n kube-system get pods | grep csi-nfs
csi-nfs-controller-5d74c65b76-h2x8v   4/4     Running   0          1m
csi-nfs-node-8jqkp                    3/3     Running   0          1m
csi-nfs-node-tx6wz                    3/3     Running   0          1m

There is one csi-nfs-node pod per node. Confirm that the driver registered with Kubernetes:

kubectl get csidrivers
NAME             ATTACHREQUIRED   PODINFOONMOUNT   STORAGECAPACITY   TOKENREQUESTS   REQUIRESREPUBLISH   MODES        AGE
nfs.csi.k8s.io   false            false            false             <unset>         false               Persistent   1m

Step 4 - Creating the StorageClass

Now define a StorageClass that points the driver at your share:

nano nfs-storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-csi
provisioner: nfs.csi.k8s.io
parameters:
  server: 10.0.0.10
  share: /srv/nfs/k8s
reclaimPolicy: Delete
volumeBindingMode: Immediate
allowVolumeExpansion: true
mountOptions:
  - nfsvers=4.1

server and share are the NFS CSI driver's parameters. Immediate binding is appropriate here because every node can reach the NFS server, so it does not matter where the pod lands.

Apply it:

kubectl apply -f nfs-storageclass.yaml
kubectl get storageclass nfs-csi
NAME      PROVISIONER      RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
nfs-csi   nfs.csi.k8s.io   Delete          Immediate           true                   5s

Step 5 - Provisioning a shared volume

Create a PVC with ReadWriteMany access, which allows pods on different nodes to mount it at the same time, and a Deployment with two replicas that both write to it:

nano shared-demo.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: storage-demo
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: shared-data
  namespace: storage-demo
spec:
  accessModes: ["ReadWriteMany"]
  storageClassName: nfs-csi
  resources:
    requests:
      storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: writer
  namespace: storage-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: writer
  template:
    metadata:
      labels:
        app: writer
    spec:
      containers:
        - name: writer
          image: busybox:1.36
          command: ["sh", "-c", "while true; do echo \"$(hostname) $(date)\" >> /data/log.txt; sleep 10; done"]
          volumeMounts:
            - name: data
              mountPath: /data
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: shared-data

Apply it and check that the claim was bound to a new PV:

kubectl apply -f shared-demo.yaml
kubectl -n storage-demo get pvc shared-data
NAME          STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
shared-data   Bound    pvc-3f1c2a4e-8b7d-4e21-9a0f-5c6d7e8f9a01   5Gi        RWX            nfs-csi        10s

After half a minute, read the file from one of the pods. Lines from both replicas prove that the volume is shared:

kubectl -n storage-demo exec deploy/writer -- tail -n 4 /data/log.txt
writer-6d9f7c8b5d-lk2pz Thu Sep 25 10:40:12 UTC 2026
writer-6d9f7c8b5d-9xw4c Thu Sep 25 10:40:13 UTC 2026
writer-6d9f7c8b5d-lk2pz Thu Sep 25 10:40:22 UTC 2026
writer-6d9f7c8b5d-9xw4c Thu Sep 25 10:40:23 UTC 2026

On the NFS server, the driver created a directory named after the PV:

ls /srv/nfs/k8s
pvc-3f1c2a4e-8b7d-4e21-9a0f-5c6d7e8f9a01

Step 6 - Making it the default StorageClass

PVCs that omit storageClassName, which is common in Helm charts, use the default StorageClass. Mark nfs-csi as default:

kubectl patch storageclass nfs-csi -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
kubectl get storageclass
NAME                PROVISIONER      RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
nfs-csi (default)   nfs.csi.k8s.io   Delete          Immediate           true                   5m

Only one class should be the default. If another class already has the annotation, set it to "false" on that class with the same command.

Step 7 - Keeping data with a Retain policy

With reclaimPolicy: Delete, deleting the PVC also deletes its directory on the NFS server. For data you never want to lose by accident, create a second class with Retain. The reclaimPolicy of an existing StorageClass cannot be changed, so it must be a new object:

nano nfs-retain-storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-csi-retain
provisioner: nfs.csi.k8s.io
parameters:
  server: 10.0.0.10
  share: /srv/nfs/k8s
reclaimPolicy: Retain
volumeBindingMode: Immediate
allowVolumeExpansion: true
mountOptions:
  - nfsvers=4.1
kubectl apply -f nfs-retain-storageclass.yaml

When a PVC from this class is deleted, its PV switches to Released and the directory stays on the server. After recovering or copying the data, delete the PV with kubectl delete pv <pv_name> and remove the directory on the NFS server manually.

You can also protect one important existing volume without changing its class, by patching the PV directly:

kubectl patch pv <pv_name> -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'

Step 8 - Cleaning up the test

Delete the demo namespace. Because the class uses Delete, the PV and its directory are removed too:

kubectl delete namespace storage-demo

A few seconds later, ls /srv/nfs/k8s on the NFS server no longer shows the pvc-... directory.

Troubleshooting

The PVC stays Pending. Run kubectl -n <namespace> describe pvc <name> and read the events. storageclass.storage.k8s.io "..." not found means a typo in storageClassName; a ProvisioningFailed event from nfs.csi.k8s.io usually means the controller cannot mount the share. Check its logs with kubectl -n kube-system logs deploy/csi-nfs-controller -c nfs.

The pod stays in ContainerCreating with mount failed. The node cannot mount NFS. Make sure nfs-common is installed on that node, that it is inside the network allowed in /etc/exports, and that port 2049 is reachable.

Permission denied when the application writes. The export is missing no_root_squash, or the application runs as a non-root user that does not own the directory. For non-root containers, set securityContext.fsGroup in the pod spec or adjust the directory ownership on the NFS server.

Conclusion

You now understand how PVCs, StorageClasses and CSI drivers work together, and you have dynamic ReadWriteMany volumes backed by NFS, with a default class for everyday use and a Retain class for critical data. Next, back up the NFS export regularly, add a block storage driver such as Longhorn for databases, and use kubectl get pv to review which volumes each class has created.