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.
  • kubectl configured 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

ObjectWho creates itWhat it represents
PersistentVolume (PV)An administrator, or a provisioner automaticallyA 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"
StorageClassAn administratorA 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 modeShort nameMeaning
ReadWriteOnceRWORead-write by Pods on a single node
ReadOnlyManyROXRead-only by many nodes
ReadWriteManyRWXRead-write by many nodes (NFS, CephFS)
ReadWriteOncePodRWOPRead-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: Delete means the volume and its data are deleted when the PVC is deleted.
  • VOLUMEBINDINGMODE: WaitForFirstConsumer means 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.

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.