Kubernetes Storage: PVCs, StorageClasses & Dynamic Provisioning
Understand how Kubernetes storage works — from PersistentVolumes to StorageClasses — and provision storage dynamically for production workloads. Covers access modes, reclaim policies, volume expansion, and common mistakes.
Before you begin
- kubectl installed and configured
- Access to a Kubernetes cluster with a CSI driver (EKS
- GKE
- or kind with local-path-provisioner)
- Basic familiarity with Kubernetes pods
Containers are ephemeral. Write a file inside a container and it disappears when the pod is deleted, rescheduled, or crashes. Kubernetes storage is the layer that survives pod lifecycle events and outlasts the containers that use it.
Understanding the storage model is a prerequisite to running any stateful workload — databases, message queues, file storage, or anything that needs data to persist. This tutorial walks through the three-level model (PV, PVC, StorageClass), shows dynamic provisioning in practice, and explains the choices you'll need to make for production workloads.
What You'll Build
A pod writing to a PersistentVolumeClaim, verified to survive a pod restart. Then a custom StorageClass so you understand what's happening when you request storage, rather than accepting defaults you don't understand.
Step 1: Understand the Three-Level Model
Pod → PVC → PV ← StorageClass (auto-provisions)
- PersistentVolume (PV): A piece of storage in the cluster. Can be a cloud disk (EBS, GCE PD), NFS share, or local directory. Created by an admin manually, or automatically by a CSI driver when a PVC requests it.
- PersistentVolumeClaim (PVC): A request for storage from a pod or StatefulSet. Specifies how much storage and what access mode. Kubernetes binds the PVC to a matching available PV.
- StorageClass: A template that tells Kubernetes how to provision a PV on demand. Points to a CSI driver, sets the disk type, reclaim policy, and other options.
Before StorageClasses existed, admins manually pre-provisioned PVs. Dynamic provisioning via StorageClasses means the PV is created on demand, sized exactly as requested, when the PVC is applied.
Step 2: Create a PVC
Start by claiming 1Gi of storage:
1kubectl apply -f - <<EOF
2apiVersion: v1
3kind: PersistentVolumeClaim
4metadata:
5 name: data-pvc
6spec:
7 accessModes:
8 - ReadWriteOnce
9 storageClassName: standard
10 resources:
11 requests:
12 storage: 1Gi
13EOFReplace standard with the appropriate StorageClass for your cluster:
- EKS:
gp2orgp3 - GKE:
standardorstandard-rwo - kind:
standard(uses rancher/local-path-provisioner) - OpenShift:
managed-premium
Check the status:
kubectl get pvc data-pvc
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
# data-pvc Bound pvc-f3a9b4c2-... 1Gi RWO standard 8sBound means a PV was found or provisioned to satisfy the claim. Pending means the PVC is waiting for a matching PV — either none exists, or the StorageClass is provisioning one (which takes a few seconds for cloud disks).
Step 3: Mount the PVC in a Pod
1kubectl apply -f - <<EOF
2apiVersion: v1
3kind: Pod
4metadata:
5 name: writer
6spec:
7 volumes:
8 - name: data
9 persistentVolumeClaim:
10 claimName: data-pvc
11 containers:
12 - name: app
13 image: busybox:1.36
14 command: ["sh", "-c", "echo 'persistent data' > /data/test.txt && sleep 3600"]
15 volumeMounts:
16 - name: data
17 mountPath: /data
18EOFkubectl wait --for=condition=ready pod writer --timeout=60s
kubectl exec writer -- cat /data/test.txt
# persistent dataStep 4: Verify Persistence Across Pod Restart
Delete the pod and create a new one using the same PVC:
kubectl delete pod writer1kubectl apply -f - <<EOF
2apiVersion: v1
3kind: Pod
4metadata:
5 name: reader
6spec:
7 volumes:
8 - name: data
9 persistentVolumeClaim:
10 claimName: data-pvc
11 containers:
12 - name: app
13 image: busybox:1.36
14 command: ["sleep", "3600"]
15 volumeMounts:
16 - name: data
17 mountPath: /data
18EOFkubectl wait --for=condition=ready pod reader --timeout=60s
kubectl exec reader -- cat /data/test.txt
# persistent dataThe file survived. The PV is a separate object from the pod — deleting the pod only unmounts the volume, it doesn't delete it.
Step 5: Access Modes
Access modes define how many nodes can mount the volume simultaneously and in what mode:
| Mode | Short | Meaning | Typical storage |
|---|---|---|---|
ReadWriteOnce | RWO | One node, read-write | EBS, GCE PD, Azure Disk |
ReadWriteMany | RWX | Multiple nodes, read-write | NFS, CephFS, EFS |
ReadOnlyMany | ROX | Multiple nodes, read-only | Pre-populated data volumes |
ReadWriteOncePod | RWOP | One pod, read-write | EBS (GA in Kubernetes 1.29; alpha 1.22–1.26, beta 1.27–1.28) |
The most critical distinction: ReadWriteOnce means one node, not one pod. Two pods on the same node can both mount an RWO volume. But if you try to schedule a second pod on a different node that needs the same RWO volume, one of them will get stuck in a ContainerCreating state waiting for the volume to be detached from the first node.
For shared storage (e.g., multiple pods writing to the same upload directory), you need ReadWriteMany — which requires NFS or a distributed filesystem. EBS and GCE PD do not support RWX.
Step 6: Reclaim Policies
When a PVC is deleted, what happens to the PV (and the underlying disk)?
kubectl describe storageclass standard | grep ReclaimPolicy
# ReclaimPolicy: DeleteThree options:
Delete(default for dynamically provisioned PVs): The PV and underlying storage are deleted when the PVC is deleted. This is what you want for ephemeral dev environments. It is dangerous on production databases.Retain: The PV survives PVC deletion. The disk is preserved and the PV entersReleasedstate. You must manually reclaim it — either delete the PV and let it be reprovisioned, or patch it to remove theclaimRefso it can be rebound.Recycle(deprecated since Kubernetes 1.9): Do not use. Performs a basicrm -rfand makes the PV available for reuse. UseDeleteorRetaininstead.
For any StorageClass backing production databases, create it with reclaimPolicy: Retain:
1kubectl apply -f - <<EOF
2apiVersion: storage.k8s.io/v1
3kind: StorageClass
4metadata:
5 name: fast-retain
6provisioner: ebs.csi.aws.com
7parameters:
8 type: gp3
9 encrypted: "true"
10reclaimPolicy: Retain
11allowVolumeExpansion: true
12volumeBindingMode: WaitForFirstConsumer
13EOFStep 7: Volume Expansion
Once a PVC is bound, you can increase its size if the StorageClass has allowVolumeExpansion: true:
kubectl patch pvc data-pvc -p '{"spec":{"resources":{"requests":{"storage":"5Gi"}}}}'kubectl describe pvc data-pvc | grep -A3 Conditions
# Conditions:
# Type Status
# FileSystemResizePending TrueThe PVC transitions through FileSystemResizePending until the node resizes the filesystem. On some providers (EBS), the pod must be restarted for the resize to complete. On others (GCE PD with ext4 online resize), it happens while the pod is running.
You cannot shrink a PVC. Kubernetes will reject any attempt to reduce spec.resources.requests.storage.
Step 8: volumeBindingMode
Two binding modes affect when a PV is provisioned:
Immediate: The PV is provisioned as soon as the PVC is created, before any pod claims it. On multi-AZ clusters, this can provision the disk in zone A while the pod eventually schedules in zone B — and the mount fails.WaitForFirstConsumer: The PV is provisioned only when a pod with a matching PVC is scheduled. The scheduler picks a node first, and the PV is created in the same zone. This is the correct mode for multi-AZ deployments.
Check which mode your default StorageClass uses:
kubectl describe storageclass standard | grep VolumeBindingMode
# VolumeBindingMode: WaitForFirstConsumerIf it's Immediate and you're on a multi-AZ cluster, create a new StorageClass with WaitForFirstConsumer and use it for stateful workloads.
Common Mistakes to Avoid
Requesting RWX on a StorageClass that only supports RWO — the PVC stays Pending indefinitely. Check the StorageClass provisioner documentation before requesting RWX.
Default Delete reclaim policy on production databases — someone runs kubectl delete pvc for cleanup and destroys the database. Create a dedicated StorageClass with reclaimPolicy: Retain for anything you can't afford to lose.
Not checking allowVolumeExpansion — kubectl patch pvc will be rejected by the API server with a Forbidden error if the StorageClass does not have allowVolumeExpansion: true. The patch does not silently succeed — verify your StorageClass supports expansion before requesting a resize.
volumeBindingMode: Immediate on multi-AZ clusters — disk provisioned in us-east-1a, pod scheduled on a node in us-east-1b. The pod fails with Multi-Attach error for volume. Switch to WaitForFirstConsumer.
Forgetting the PVC keeps the PV alive — even after all pods using a PVC are deleted, the PVC object itself keeps the PV bound. The PV is only released when the PVC is deleted. In dev environments, leftover PVCs accumulate and eat into storage quotas.
Cleanup
kubectl delete pod reader
kubectl delete pvc data-pvc
# With ReclaimPolicy=Delete, the PV is deleted automatically
# Verify:
kubectl get pvWhat's Next
- Running StatefulSets: Deploy a PostgreSQL Cluster — uses PVCs with VolumeClaimTemplates for per-replica persistent storage
Official References
- Persistent Volumes — full reference for PV lifecycle, access modes, reclaim policies, and phase transitions
- Storage Classes — configuring StorageClasses for different provisioners and disk types
- Expanding Persistent Volumes — requirements and process for online volume expansion
- CSI Drivers — list of CSI drivers for cloud providers and storage systems
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.