Containers lose everything written to their filesystem when they are replaced. Kubernetes solves this with PersistentVolumes (PV), which represent a piece of storage, and PersistentVolumeClaims (PVC), which are requests for storage made by an application. In this tutorial you will set up dynamic provisioning with a StorageClass, attach a volume to a Pod and prove that the data survives, give each replica of a StatefulSet its own volume, and mount a shared NFS volume on several Pods.
Prerequisites
To follow this tutorial you need:
- A Kubernetes cluster (1.30 or later) with at least one worker node, for example one built on CubePath VPS instances with kubeadm on Ubuntu 24.04.
kubectlconfigured with cluster admin access.- For Step 5 only: an NFS server reachable from the worker nodes, exporting a directory such as
/srv/nfs/shared.
How storage objects fit together
| Object | Who creates it | What it represents |
|---|---|---|
| PersistentVolume (PV) | An administrator, or a provisioner automatically | A real piece of storage: a disk, a directory, an NFS export |
| PersistentVolumeClaim (PVC) | The application owner | "I need 5 GiB, mounted read-write by one node" |
| StorageClass | An administrator | A type of storage and the provisioner that creates PVs for it on demand |
When a PVC is created, Kubernetes binds it to a PV that satisfies it. With a StorageClass, the PV is created on the fly (dynamic provisioning); without one, an administrator must create matching PVs in advance (static provisioning).
The access mode says how a volume can be mounted:
| Access mode | Short name | Meaning |
|---|---|---|
ReadWriteOnce | RWO | Read-write by Pods on a single node |
ReadOnlyMany | ROX | Read-only by many nodes |
ReadWriteMany | RWX | Read-write by many nodes (NFS, CephFS) |
ReadWriteOncePod | RWOP | Read-write by a single Pod |
Block storage such as local disks only supports ReadWriteOnce. Use a network filesystem when several Pods on different nodes must write to the same data.
Step 1 - Checking for a StorageClass
Managed Kubernetes services normally include a default StorageClass. A cluster built with kubeadm has none:
kubectl get storageclass
No resources found
Without a StorageClass, any PVC stays in Pending forever. If your cluster already lists a class marked (default), you can skip to Step 3 and use it.
Step 2 - Installing a dynamic provisioner
The Rancher local-path-provisioner creates volumes as directories on the node where the Pod runs (under /opt/local-path-provisioner). It is simple and fast, and suits single-node clusters, labs and applications that replicate their own data. It does not protect against a node failure: if the node is lost, so is the volume.
Install it from a tagged release manifest. Check the project's releases page for a newer version:
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.31/deploy/local-path-storage.yaml
Wait for the provisioner Pod:
kubectl get pods -n local-path-storage
NAME READY STATUS RESTARTS AGE
local-path-provisioner-7b8c9d6f5d-kx9qz 1/1 Running 0 20s
Mark the new local-path class as the default, so PVCs without storageClassName use it:
kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 1m
Two columns matter here:
RECLAIMPOLICY: Deletemeans the volume and its data are deleted when the PVC is deleted.VOLUMEBINDINGMODE: WaitForFirstConsumermeans the PV is only created once a Pod uses the PVC, so it lands on the node where that Pod is scheduled.
Step 3 - Creating a PVC and using it in a Pod
Create a claim for 1 GiB and a Pod that mounts it at /data:
nano pvc-demo.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-demo
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
name: writer
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep infinity"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: data-demo
Apply it and check the claim:
kubectl apply -f pvc-demo.yaml
kubectl get pvc data-demo
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
data-demo Bound pvc-3f1c2a9e-8b7d-4c61-9e0a-2d5b6c7e8f90 1Gi RWO local-path 15s
The status is Bound, and kubectl get pv shows the volume the provisioner created for it.
Write a file to the volume:
kubectl exec writer -- sh -c 'echo "persisted at $(date)" > /data/hello.txt'
Now delete the Pod, recreate it from the same file and read the data back:
kubectl delete pod writer
kubectl apply -f pvc-demo.yaml
kubectl wait --for=condition=Ready pod/writer --timeout=60s
kubectl exec writer -- cat /data/hello.txt
persisted at Fri Sep 25 10:12:03 UTC 2026
The file survived because it lives on the volume, not in the container. In a real application, reference the PVC the same way from a Deployment's Pod template. With ReadWriteOnce, run such a Deployment with one replica, or use a StatefulSet as shown next.
Step 4 - Giving each StatefulSet replica its own volume
Databases and other clustered services need one volume per replica that follows that replica when it is rescheduled. A StatefulSet does this with volumeClaimTemplates: it creates one PVC per Pod, named <template>-<statefulset>-<ordinal>.
Create a StatefulSet with three replicas and a headless Service, which StatefulSets require for stable network names:
nano statefulset-demo.yaml
apiVersion: v1
kind: Service
metadata:
name: web
spec:
clusterIP: None
selector:
app: web
ports:
- port: 80
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: web
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.28
ports:
- containerPort: 80
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
Apply it. The Pods start one after another, in order:
kubectl apply -f statefulset-demo.yaml
kubectl rollout status statefulset/web
kubectl get pvc -l app=web
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
www-web-0 Bound pvc-0a1b... 1Gi RWO local-path 50s
www-web-1 Bound pvc-4c5d... 1Gi RWO local-path 35s
www-web-2 Bound pvc-8e9f... 1Gi RWO local-path 20s
Write a different page into each replica and confirm that web-1 keeps its own data after being deleted:
for i in 0 1 2; do kubectl exec "web-$i" -- sh -c "echo replica-$i > /usr/share/nginx/html/index.html"; done
kubectl delete pod web-1
kubectl wait --for=condition=Ready pod/web-1 --timeout=60s
kubectl exec web-1 -- cat /usr/share/nginx/html/index.html
replica-1
The recreated web-1 reattached www-web-1. Deleting or scaling down a StatefulSet does not delete its PVCs; that protects your data, but you must remove them yourself when you no longer need them.
Step 5 - Mounting a shared NFS volume
When several Pods on different nodes must read and write the same files (uploaded media, shared assets), use a ReadWriteMany volume. The simplest option is a statically defined PV that points to an NFS export.
Every worker node needs the NFS client to mount the share. Run this on each worker:
sudo apt install -y nfs-common
Create the PV and a PVC that binds to it. Setting storageClassName: "" on both disables dynamic provisioning for this claim, so it binds to your PV instead of getting a new local-path volume. Replace nfs_server_ip with your NFS server's address:
nano nfs-volume.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: shared-nfs
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions:
- nfsvers=4.1
nfs:
server: nfs_server_ip
path: /srv/nfs/shared
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-nfs
spec:
accessModes:
- ReadWriteMany
storageClassName: ""
volumeName: shared-nfs
resources:
requests:
storage: 10Gi
persistentVolumeReclaimPolicy: Retain keeps the data on the NFS server if the claim is deleted. volumeName pins the claim to this exact PV.
Apply it and check that both are bound:
kubectl apply -f nfs-volume.yaml
kubectl get pv shared-nfs
kubectl get pvc shared-nfs
Mount the claim in a Deployment with several replicas, exactly as in Step 3 but with claimName: shared-nfs. All replicas see the same files, whichever node they run on.
Step 6 - Changing the reclaim policy of important volumes
Dynamically provisioned volumes inherit the StorageClass reclaim policy, which is usually Delete. For a volume that holds data you cannot lose, switch it to Retain so an accidental kubectl delete pvc does not destroy the data:
kubectl get pvc data-demo -o jsonpath='{.spec.volumeName}'
kubectl patch pv your_pv_name -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
After the claim is deleted, a retained PV moves to Released and keeps its data. It is not reused automatically; an administrator must recover the data or delete the PV by hand.
ImportantA PersistentVolume is not a backup. A
local-pathvolume disappears with its node, and a mistake inside the application deletes data on any volume type. Back up important data outside the cluster.
Step 7 - Cleaning up
Delete the demo workloads, then their claims:
kubectl delete -f statefulset-demo.yaml -f pvc-demo.yaml
kubectl delete pvc -l app=web
kubectl delete -f nfs-volume.yaml
Volumes with the Delete policy are removed with their claims. The files on the NFS server stay untouched.
Troubleshooting
PVC stuck in Pending. Run kubectl describe pvc <name>. no persistent volumes available for this claim and no storage class is set means there is no default StorageClass (Step 2). With local-path, waiting for first consumer to be created is normal until a Pod uses the claim.
Pod stuck in ContainerCreating with a mount error. For NFS, check that nfs-common is installed on the node, that the export allows the node's IP, and test manually with sudo mount -t nfs nfs_server_ip:/srv/nfs/shared /mnt on that node.
Pod Pending with volume node affinity conflict. A local-path volume is tied to the node where it was created. The Pod cannot be scheduled elsewhere; bring that node back or delete the PVC (and its data) to start fresh.
PVC stuck in Terminating. The kubernetes.io/pvc-protection finalizer keeps a claim alive while a Pod still uses it. Find and delete that Pod rather than removing the finalizer: kubectl describe pvc <name> lists it under Used By.
Conclusion
You installed a dynamic provisioner, created claims that survive Pod restarts, gave each StatefulSet replica its own volume and mounted a shared NFS volume across nodes. You also saw how reclaim policies decide whether data outlives its claim.
For production, choose storage that matches the failure you need to survive: replicated block storage through a CSI driver such as Longhorn or Rook Ceph, or a managed database instead of running one yourself. Combine it with regular backups, and publish your stateful applications with an Ingress.
