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
kubectlwith 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.10for the NFS server and10.0.0.0/24for the private network; replace both with your own values. - A non-root user with
sudoon the NFS server and on each node.
How StorageClasses and CSI drivers fit together
When a pod needs persistent storage, three objects are involved:
| Object | Created by | Purpose |
|---|---|---|
| PersistentVolumeClaim (PVC) | The application owner | "I need 10 GiB, mounted read-write by one or many pods" |
| StorageClass | The cluster administrator | Which CSI driver to use and with which parameters |
| PersistentVolume (PV) | The CSI driver, automatically | The 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 ownparameters, which Kubernetes passes through without validating.reclaimPolicy: what happens to the volume when the PVC is deleted.Delete(the default) removes the data;Retainkeeps the PV and its data for manual recovery. The oldRecyclepolicy is deprecated.volumeBindingMode:Immediatecreates the volume as soon as the PVC exists.WaitForFirstConsumerwaits 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 editingspec.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:
| Driver | Access modes | Good for |
|---|---|---|
NFS CSI driver (nfs.csi.k8s.io) | RWO, RWX | Shared files, simple setup, an existing NFS server |
Longhorn (driver.longhorn.io) | RWO, RWX | Replicated block storage using the nodes' local disks |
| Ceph CSI via Rook (RBD and CephFS) | RWO, RWX | Large clusters with dedicated storage nodes |
local-path-provisioner (rancher.io/local-path) | RWO | Single-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
NoteNFS does not enforce the requested size. A 5Gi claim can grow until the whole export is full, so monitor the free space on the NFS server.
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.
