A container's filesystem disappears when the container is replaced, so anything that must survive, such as database files, uploads or a message broker's log, needs a volume that lives outside the Pod. In Kubernetes that means a persistent volume. The design splits the problem in two: a PersistentVolume (PV) represents a piece of real storage, and a PersistentVolumeClaim (PVC) is an application's request for storage. A StorageClass connects them automatically, so disks get created on demand.
This guide covers the ephemeral volume types, how PVs and PVCs bind, static versus dynamic provisioning, what CSI drivers actually do, access modes, and how to get shared ReadWriteMany storage with NFS.
Volumes that live and die with the Pod
Not every volume is persistent. These are tied to the Pod's lifecycle:
emptyDir: an empty directory created when the Pod starts. It survives container restarts inside the Pod but is deleted when the Pod is removed. Good for scratch space and for sharing files between containers in one Pod.medium: Memorybacks it with RAM.configMap,secret,projected: expose configuration and credentials as files.hostPath: mounts a directory from the node. The data stays on that one node, and it gives the Pod access to the host filesystem. Keep it for node agents such as log collectors, not for application data.
volumes:
- name: scratch
emptyDir:
sizeLimit: 1GiFor data that must outlive the Pod, you need a PersistentVolume.
PersistentVolume and PersistentVolumeClaim
A PV is a cluster-scoped object that describes real storage: a cloud disk, an NFS export, a Ceph volume. It has a capacity, access modes and a reclaim policy.
A PVC is a namespaced request: "I need 20Gi that one node can read and write." Kubernetes finds a PV that satisfies it (enough capacity, a matching access mode and StorageClass) and binds the two one-to-one. The Pod then refers only to the claim:
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: ghcr.io/example/app:2.0.0
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: app-dataThis indirection is the point. Developers ask for storage without knowing whether it's EBS, a SAN or NFS, and the same manifest works across clusters.
What happens to the PV when its claim is deleted depends on persistentVolumeReclaimPolicy:
| Policy | On PVC deletion | Use when |
|---|---|---|
Delete | The PV and the underlying disk are deleted | Disposable or easily rebuilt data. Default for dynamic provisioning |
Retain | The PV becomes Released and the disk is kept for manual cleanup | Data you can't afford to lose by accident |
The old Recycle policy is deprecated. For important data, use Retain or set it on the StorageClass.
Static provisioning: an admin creates PVs up front
With static provisioning, an administrator creates the storage and writes a PV for it, and a PVC binds to it later:
apiVersion: v1
kind: PersistentVolume
metadata:
name: reports-nfs
spec:
capacity:
storage: 50Gi
accessModes: ["ReadWriteMany"]
persistentVolumeReclaimPolicy: Retain
nfs:
server: nfs.internal.example.com
path: /exports/reports
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: reports
namespace: analytics
spec:
accessModes: ["ReadWriteMany"]
storageClassName: "" # don't use dynamic provisioning
volumeName: reports-nfs # bind to this specific PV
resources:
requests:
storage: 50GistorageClassName: "" matters. Without it, the default StorageClass would try to provision a new volume instead of binding to yours.
Static provisioning works for a few pre-existing shares. It doesn't scale: every new disk means a ticket to an admin, and PVs created in advance either run out or sit unused.
Dynamic provisioning with StorageClass
A StorageClass is a template for creating volumes on demand. When a PVC names a class, its provisioner creates the disk and the PV automatically, sized to the request.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
parameters:
type: gp3
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources:
requests:
storage: 20GiThe fields that matter:
provisioner: the CSI driver that creates volumes, for exampleebs.csi.aws.com,pd.csi.storage.gke.ioordobs.csi.digitalocean.com.parameters: driver-specific settings such as disk type, IOPS or filesystem.volumeBindingMode: WaitForFirstConsumer: delays creating the disk until a Pod using the claim is scheduled. Cloud disks are zonal, and creating one before the scheduler has picked a node can leave the disk in a zone where the Pod can't run. Prefer this mode for zonal block storage.allowVolumeExpansion: true: lets you grow a claim by editingspec.resources.requests.storage. Volumes can grow but never shrink.- Default class: a PVC with no
storageClassNamegets the class annotated as default.
Check what exists with kubectl get storageclass, then kubectl get pvc,pv to see bindings. With a StatefulSet you don't write PVCs at all: volumeClaimTemplates creates one per Pod, as shown in the guide to Kubernetes workloads.
CSI: how Kubernetes talks to storage
The Container Storage Interface (CSI) is the standard contract between Kubernetes and storage systems. A storage vendor writes a CSI driver that implements a set of gRPC calls, and Kubernetes uses it without any vendor code in the Kubernetes codebase. The old in-tree cloud volume plugins have been migrated to CSI drivers.
A CSI driver usually has two parts:
- Controller plugin, often a Deployment with sidecars such as external-provisioner and external-attacher. It handles
CreateVolume,DeleteVolumeandControllerPublishVolume, which attaches a disk to a node. - Node plugin, a DaemonSet on every node. It handles
NodeStageVolume, which formats the device if needed and mounts it at a staging path on the node, andNodePublishVolume, which mounts it into the Pod's directory.
Following a dynamic claim end to end:
- You create a PVC that names a StorageClass.
- With
WaitForFirstConsumer, nothing happens until a Pod using the claim is scheduled to a node. - The external-provisioner calls
CreateVolume. A PV is created and bound to the PVC. - The attacher calls
ControllerPublishVolumeto attach the disk to the chosen node. - The kubelet calls
NodeStageVolumeandNodePublishVolume, and the container sees the volume at itsmountPath.
When a Pod is stuck in ContainerCreating with a volume error, this sequence tells you which component to check. The Kubernetes architecture guide shows where the scheduler and kubelet fit in. CSI also enables VolumeSnapshot (snapshot.storage.k8s.io/v1) for drivers that support it, which is useful for backups.
Access modes
| Mode | Short | Meaning |
|---|---|---|
ReadWriteOnce | RWO | Read-write by one node at a time. Several Pods on that node can share it |
ReadOnlyMany | ROX | Read-only by many nodes |
ReadWriteMany | RWX | Read-write by many nodes at once |
ReadWriteOncePod | RWOP | Read-write by exactly one Pod in the whole cluster (CSI only) |
RWO is often described as "one Pod", but the limit is one node. If you need a hard single-writer guarantee, use ReadWriteOncePod.
What you get depends on the storage type. Block storage (EBS, Persistent Disk, DigitalOcean Volumes, Ceph RBD) attaches to one machine at a time, so it offers RWO. File storage (NFS, EFS, Azure Files, CephFS) is shared over the network, so it can offer RWX.
NFS and ReadWriteMany storage
NFS (Network File System) exports a directory from a server, and many machines can mount it at the same time. That makes it one of the most common ways to get ReadWriteMany, for example when several replicas of a web app need the same upload directory.
There are three common setups:
- An existing NFS server with static PVs, as in the earlier example. Simple, but every share is created by hand.
- An existing NFS server with dynamic provisioning. csi-driver-nfs creates a subdirectory on the share for each new PVC.
- An NFS server running inside the cluster. nfs-server-provisioner (now maintained as nfs-ganesha-server-and-external-provisioner) runs an NFS server as a Pod, backed by its own volume, and creates a directory for each PVC. This is useful on platforms whose block storage only supports RWO.
A StorageClass for csi-driver-nfs, and a shared claim:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-csi
provisioner: nfs.csi.k8s.io
parameters:
server: nfs.internal.example.com
share: /exports/k8s
reclaimPolicy: Delete
volumeBindingMode: Immediate
mountOptions:
- nfsvers=4.1
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: uploads
spec:
accessModes: ["ReadWriteMany"]
storageClassName: nfs-csi
resources:
requests:
storage: 100GiEvery replica of a Deployment can now mount uploads, on any node.
Know the limits before you rely on it:
- An in-cluster NFS server is a single Pod on a single volume. If it goes down, every Pod that uses it stalls.
- NFS adds network latency to every file operation and handles locking less predictably than a local disk. Don't put database data directories on it. Use RWO block storage per replica, as described in running databases in production.
- For user uploads, object storage such as S3 is often a better fit than a shared filesystem.
Troubleshooting persistent volumes
| Symptom | Likely cause |
|---|---|
PVC Pending, "waiting for first consumer" | WaitForFirstConsumer: it binds once a Pod uses it. This is normal |
PVC Pending, "no persistent volumes available" | No default StorageClass, a misspelled class, or no static PV matches |
Pod Pending, "unbound immediate PersistentVolumeClaims" | The claim couldn't be provisioned. Run kubectl describe pvc |
Multi-Attach error | An RWO volume is needed on a second node, often during a Deployment rolling update |
| "volume node affinity conflict" | The disk is in a different zone from the node the Pod was scheduled on |
ContainerCreating with mount timeouts | NFS server unreachable, or the NFS client tools are missing on the node |
For the Multi-Attach case with a single-replica Deployment, use strategy: Recreate or switch to a StatefulSet, so the old Pod releases the disk before the new one needs it.
FAQ
What is the difference between a PV and a PVC?
A PV is the actual storage, a cluster-wide resource. A PVC is a namespaced request for storage. Pods reference PVCs, and Kubernetes binds each PVC to one PV.
What happens to my data when I delete a PVC?
It depends on the reclaim policy. With Delete, the default for dynamic provisioning, the disk is deleted too. With Retain, the PV and its data remain until an admin cleans them up.
Can multiple Pods share one PersistentVolumeClaim?
Yes. With RWO, only Pods on the same node can share it. For Pods across nodes, you need an RWX volume such as NFS, EFS or CephFS.
Can I resize a PersistentVolumeClaim?
You can grow it if the StorageClass has allowVolumeExpansion: true and the driver supports expansion. Edit the requested size on the PVC. Shrinking isn't supported.
Static vs dynamic provisioning: which should I use?
Use dynamic provisioning with a StorageClass for almost everything. Static PVs make sense for existing storage you want to expose, such as a legacy NFS share.
Storage checklist
- A default StorageClass exists, and zonal block storage uses
WaitForFirstConsumer. - Important data uses
reclaimPolicy: Retain, or it's backed up with snapshots. -
allowVolumeExpansionis enabled where the driver supports it. - RWO for databases (one volume per replica). RWX only where sharing is truly needed.
- You know what happens to your PVCs on scale-down, deletion and node failure.
If you'd like to practice setting up StorageClasses and PVCs on a real cluster, Vectorkub's free DevOps courses include hands-on labs.
