Kubernetes Storage: PersistentVolumes, PVCs and StorageClasses (2026)
1. Kubernetes Storage Concepts: Ephemeral vs Persistent
Kubernetes runs stateless workloads effortlessly, but real-world applications — databases, message brokers, file-processing pipelines — all need durable storage that survives pod restarts, rescheduling, and node failures. Understanding how Kubernetes models storage is the first step to building reliable stateful systems in 2026.
Ephemeral Storage
Ephemeral volumes exist only as long as the pod that owns them. Common types include:
- emptyDir — a scratch directory created when a pod is assigned to a node; destroyed when the pod is removed.
- configMap / secret — project configuration and credentials into the filesystem; backed by cluster state, not a real disk.
- downwardAPI — expose pod metadata (labels, annotations) as files.
emptyDir volume is killed and rescheduled to a
different node, all data in that volume is gone. Use persistent storage for anything you cannot afford to lose.
Persistent Storage and the PV/PVC/StorageClass Triangle
Kubernetes separates what storage exists from what storage is requested via a three-layer abstraction:
- PersistentVolume (PV) — a piece of storage provisioned by an administrator or dynamically by a CSI driver. It is a cluster-level resource, independent of any pod or namespace.
- PersistentVolumeClaim (PVC) — a namespaced request for storage made by a user or workload. Kubernetes binds the PVC to a suitable PV.
- StorageClass — a template that describes how to provision storage (which provisioner to use, what disk type, replication factor, etc.). Dynamic provisioning creates a new PV automatically whenever a PVC referencing the StorageClass is created.
Think of it this way: a StorageClass is a recipe, a PVC is an order, and a PV is the dish delivered to the pod's table. For a broader overview of cluster architecture, see our Complete Kubernetes Guide.
2. PersistentVolume Spec: The Full YAML Reference
A PV describes a real storage resource. Here is a fully-annotated example using an AWS EBS volume provisioned statically (i.e., the disk already exists):
# pv-ebs-static.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-ebs-mysql
labels:
app: mysql
environment: production
spec:
# Total capacity of this volume
capacity:
storage: 50Gi
# volumeMode: Filesystem (default) mounts as a directory.
# Use Block for raw block device access (databases that manage their own I/O).
volumeMode: Filesystem
# accessModes define how the volume can be mounted:
# ReadWriteOnce (RWO) — one node can mount read/write
# ReadOnlyMany (ROX) — many nodes can mount read-only
# ReadWriteMany (RWX) — many nodes can mount read/write (requires NFS/EFS)
# ReadWriteOncePod — only one pod (not just node) can mount R/W (K8s 1.22+)
accessModes:
- ReadWriteOnce
# reclaimPolicy controls what happens to the PV when its PVC is deleted:
# Retain — keep the volume and data; admin must manually reclaim
# Delete — automatically delete the underlying storage asset
# Recycle — deprecated; use dynamic provisioning instead
persistentVolumeReclaimPolicy: Retain
# storageClassName must match the PVC's storageClassName for binding
storageClassName: gp3-retain
# mountOptions are passed to the OS mount command
mountOptions:
- discard # TRIM support for SSDs
# nodeAffinity restricts which nodes can access this volume.
# Required for zone-specific block devices like EBS.
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- ap-south-1a
# Plugin-specific config. For EBS CSI driver use csi: block, not awsElasticBlockStore.
csi:
driver: ebs.csi.aws.com
volumeHandle: vol-0a1b2c3d4e5f67890 # existing EBS volume ID
fsType: ext4
awsElasticBlockStore in-tree plugin is removed in Kubernetes
1.27+. Always use the EBS CSI driver (ebs.csi.aws.com) on modern clusters.
Access Mode Cheat Sheet
| Mode | Short Code | Use Case |
|---|---|---|
| ReadWriteOnce | RWO | Databases (MySQL, Postgres), single-writer apps |
| ReadOnlyMany | ROX | Shared config, static assets read by many pods |
| ReadWriteMany | RWX | Shared uploads, NFS, EFS — multiple writers |
| ReadWriteOncePod | RWOP | Exclusive single-pod access (K8s 1.22+, CSI only) |
3. PersistentVolumeClaim: Requesting Storage
A PVC is a namespaced object that asks for a volume meeting specific criteria. Kubernetes' control plane watches for unbound PVCs and attempts to bind them to compatible PVs.
# pvc-mysql.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data-pvc
namespace: production
annotations:
# Optional: document intended use
app.techoral.com/purpose: "MySQL primary datadir"
spec:
# Must match a PV's accessModes (or a superset)
accessModes:
- ReadWriteOnce
# volumeMode must match the PV
volumeMode: Filesystem
resources:
requests:
# Kubernetes binds the smallest PV that satisfies this request
storage: 20Gi
# Referencing a StorageClass triggers dynamic provisioning.
# Leave blank ("") to use the cluster default StorageClass.
storageClassName: gp3-retain
# Optional: selector pins the PVC to a specific PV using labels
selector:
matchLabels:
app: mysql
environment: production
Binding Lifecycle
A PVC moves through these phases:
- Pending — no suitable PV found yet (or WaitForFirstConsumer mode is active).
- Bound — a PV is matched and exclusively reserved for this PVC.
- Lost — the bound PV has been deleted while the PVC still exists (data potentially gone).
Static vs Dynamic Provisioning
Static provisioning: An administrator pre-creates PVs manually. The PVC binds to one of
them. Good for on-premises hardware or pre-existing cloud volumes.
Dynamic provisioning: A StorageClass and its provisioner (CSI driver) automatically create
a new PV — and the underlying cloud disk — when a PVC is submitted. This is the standard approach in 2026
for cloud-native workloads.
spec.volumes[].persistentVolumeClaim.claimName.
The volume is then mounted at the path specified in spec.containers[].volumeMounts[].mountPath.
# pod using the PVC above
apiVersion: v1
kind: Pod
metadata:
name: mysql-pod
namespace: production
spec:
containers:
- name: mysql
image: mysql:8.4
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: root-password
volumeMounts:
- name: mysql-storage
mountPath: /var/lib/mysql
volumes:
- name: mysql-storage
persistentVolumeClaim:
claimName: mysql-data-pvc
4. StorageClass: The Provisioning Template
StorageClasses define the "type" of storage available in your cluster. Each cloud provider ships CSI drivers that read StorageClass parameters and translate them into API calls (create EBS volume, create EFS access point, etc.).
# storageclass-gp3.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3
annotations:
# Mark as default — PVCs without storageClassName use this class
storageclass.kubernetes.io/is-default-class: "true"
# The CSI driver responsible for provisioning
provisioner: ebs.csi.aws.com
# Parameters are driver-specific
parameters:
type: gp3 # AWS EBS volume type (gp3 is the 2026 default)
iops: "3000" # Baseline IOPS (free tier for gp3)
throughput: "125" # MB/s throughput
encrypted: "true" # Encrypt at rest using default KMS key
# kmsKeyId: "arn:aws:kms:..." # Use a custom KMS key (optional)
# What happens to the EBS volume when the PVC is deleted
reclaimPolicy: Delete
# Expand volumes online without downtime (requires EBS CSI 1.x+)
allowVolumeExpansion: true
# WaitForFirstConsumer defers PV creation until a pod is scheduled,
# ensuring the EBS volume is created in the correct AZ.
volumeBindingMode: WaitForFirstConsumer
# Restrict which topologies (AZs) this class can provision into
allowedTopologies:
- matchLabelExpressions:
- key: topology.kubernetes.io/zone
values:
- ap-south-1a
- ap-south-1b
- ap-south-1c
WaitForFirstConsumer with
zone-bound storage (EBS, Azure Disk). Immediate binding can create volumes in the wrong AZ,
causing pods to be unschedulable.
5. Dynamic Provisioning End-to-End: StorageClass → PVC → Pod
The following example deploys a PostgreSQL database using fully dynamic storage. Apply the files in order:
# 1-storageclass.yaml — define the storage type
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: postgres-gp3
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
reclaimPolicy: Retain # Keep data even if PVC is deleted (safer for DBs)
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
---
# 2-pvc.yaml — request 100 GB
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: default
spec:
accessModes:
- ReadWriteOnce
storageClassName: postgres-gp3
resources:
requests:
storage: 100Gi
---
# 3-deployment.yaml — mount the PVC
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
ports:
- containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data
# Apply all three manifests
kubectl apply -f 1-storageclass.yaml
kubectl apply -f 2-pvc.yaml
kubectl apply -f 3-deployment.yaml
# Watch the PVC move from Pending → Bound once the pod is scheduled
kubectl get pvc postgres-data -w
# Verify the automatically created PV
kubectl get pv
# Confirm the pod is running and storage is mounted
kubectl describe pod -l app=postgres | grep -A5 Volumes
For guidance on structuring multi-replica deployments, see our Kubernetes Deployments guide.
6. AWS EBS CSI Driver: Installation and Configuration
The EBS CSI driver is the standard way to provision Amazon EBS volumes in EKS clusters. Starting with EKS 1.23, the in-tree EBS plugin is disabled and the CSI driver is mandatory.
Step 1 — Create IAM Policy and IRSA
# Download the official IAM policy
curl -o ebs-csi-iam-policy.json \
https://raw.githubusercontent.com/kubernetes-sigs/aws-ebs-csi-driver/master/docs/example-iam-policy.json
# Create the policy in AWS
aws iam create-policy \
--policy-name AmazonEKS_EBS_CSI_Driver_Policy \
--policy-document file://ebs-csi-iam-policy.json
# Create an IAM role bound to the CSI driver ServiceAccount using IRSA
eksctl create iamserviceaccount \
--name ebs-csi-controller-sa \
--namespace kube-system \
--cluster my-cluster \
--attach-policy-arn arn:aws:iam::123456789012:policy/AmazonEKS_EBS_CSI_Driver_Policy \
--approve \
--role-only \
--role-name AmazonEKS_EBS_CSI_DriverRole
Step 2 — Install via Helm
helm repo add aws-ebs-csi-driver \
https://kubernetes-sigs.github.io/aws-ebs-csi-driver
helm repo update
helm upgrade --install aws-ebs-csi-driver \
aws-ebs-csi-driver/aws-ebs-csi-driver \
--namespace kube-system \
--set controller.serviceAccount.create=false \
--set controller.serviceAccount.name=ebs-csi-controller-sa \
--set node.serviceAccount.create=false \
--set node.serviceAccount.name=ebs-csi-controller-sa
# Verify pods are running
kubectl get pods -n kube-system -l app.kubernetes.io/name=aws-ebs-csi-driver
Step 3 — Annotated ServiceAccount (IRSA)
# The eksctl command above creates this; shown for reference
apiVersion: v1
kind: ServiceAccount
metadata:
name: ebs-csi-controller-sa
namespace: kube-system
annotations:
# This annotation links the K8s SA to the IAM Role via OIDC
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/AmazonEKS_EBS_CSI_DriverRole
aws eks create-addon --cluster-name my-cluster --addon-name aws-ebs-csi-driver.
This simplifies upgrades but gives less control over Helm values.
7. AWS EFS CSI Driver: ReadWriteMany Shared Storage
EBS volumes only support ReadWriteOnce — one node at a time. When multiple pods across
different nodes need to share the same storage (e.g., a shared upload directory, ML model weights),
use Amazon EFS with the EFS CSI driver which supports ReadWriteMany.
Install EFS CSI Driver
helm repo add aws-efs-csi-driver \
https://kubernetes-sigs.github.io/aws-efs-csi-driver/
helm repo update
helm upgrade --install aws-efs-csi-driver \
aws-efs-csi-driver/aws-efs-csi-driver \
--namespace kube-system \
--set controller.serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=\
arn:aws:iam::123456789012:role/AmazonEKS_EFS_CSI_DriverRole
StorageClass, PVC and Pod for EFS
# efs-storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: efs-sc
provisioner: efs.csi.aws.com
parameters:
provisioningMode: efs-ap # Use EFS Access Points for isolation
fileSystemId: fs-0123456789abcdef # Your EFS file system ID
directoryPerms: "700"
gidRangeStart: "1000"
gidRangeEnd: "2000"
basePath: "/apps"
---
# efs-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-uploads
namespace: default
spec:
accessModes:
- ReadWriteMany # Multiple pods on different nodes can read/write
storageClassName: efs-sc
resources:
requests:
storage: 5Gi # EFS is elastic; this value is advisory only
---
# deployment using shared EFS volume (scale to many replicas freely)
apiVersion: apps/v1
kind: Deployment
metadata:
name: file-processor
spec:
replicas: 3
selector:
matchLabels:
app: file-processor
template:
metadata:
labels:
app: file-processor
spec:
containers:
- name: app
image: nginx:1.27
volumeMounts:
- name: uploads
mountPath: /usr/share/nginx/html/uploads
volumes:
- name: uploads
persistentVolumeClaim:
claimName: shared-uploads
ReadWriteMany or cross-AZ access.
Use EBS when you need high-IOPS single-writer performance (databases). EFS costs more per GB but scales
automatically with no pre-provisioning.
8. Volume Snapshots: Backup and Restore
Kubernetes 1.20+ provides a stable Volume Snapshot API (backed by the external-snapshotter controller) that works alongside CSI drivers to create point-in-time snapshots of PVCs.
# 1. VolumeSnapshotClass — defines which CSI driver handles snapshots
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: ebs-vsc
annotations:
snapshot.storage.kubernetes.io/is-default-class: "true"
driver: ebs.csi.aws.com
deletionPolicy: Delete # Delete snapshot when VolumeSnapshot object is deleted
parameters:
tagSpecification_1: "Name=k8s-snapshot-{{.VolumeSnapshotName}}"
---
# 2. VolumeSnapshot — take a snapshot of an existing PVC
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: postgres-data-snap-20260610
namespace: default
spec:
volumeSnapshotClassName: ebs-vsc
source:
persistentVolumeClaimName: postgres-data # PVC to snapshot
# Watch snapshot until readyToUse: true
kubectl get volumesnapshot postgres-data-snap-20260610 -w
# List all snapshots
kubectl get volumesnapshot -A
# 3. Restore — create a new PVC from the snapshot
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data-restored
namespace: default
spec:
accessModes:
- ReadWriteOnce
storageClassName: postgres-gp3
resources:
requests:
storage: 100Gi
# dataSource points to the snapshot instead of an empty volume
dataSource:
name: postgres-data-snap-20260610
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
dataSourceRef with a
VolumeSnapshotContent reference if you need to restore a snapshot into a different namespace.
This requires the cross-namespace data source feature gate (beta in K8s 1.26+).
9. StatefulSet with volumeClaimTemplates
StatefulSets are the standard way to run stateful applications
like databases and message brokers in Kubernetes. Each pod in a StatefulSet gets its own dedicated PVC
created automatically from a volumeClaimTemplates spec — no manual PVC creation needed.
# statefulset-cassandra.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: cassandra
namespace: default
spec:
serviceName: cassandra # Headless service name (required)
replicas: 3
selector:
matchLabels:
app: cassandra
template:
metadata:
labels:
app: cassandra
spec:
terminationGracePeriodSeconds: 180
containers:
- name: cassandra
image: cassandra:5.0
ports:
- containerPort: 9042
name: cql
- containerPort: 7000
name: intra-node
env:
- name: MAX_HEAP_SIZE
value: "512M"
- name: HEAP_NEWSIZE
value: "100M"
- name: CASSANDRA_SEEDS
value: "cassandra-0.cassandra.default.svc.cluster.local"
volumeMounts:
- name: cassandra-data
mountPath: /var/lib/cassandra/data
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
# volumeClaimTemplates create one PVC per pod replica:
# cassandra-data-cassandra-0
# cassandra-data-cassandra-1
# cassandra-data-cassandra-2
volumeClaimTemplates:
- metadata:
name: cassandra-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: gp3
resources:
requests:
storage: 200Gi
---
# Headless service required by StatefulSet for stable DNS
apiVersion: v1
kind: Service
metadata:
name: cassandra
namespace: default
spec:
clusterIP: None # Headless — no load balancing, returns pod IPs directly
selector:
app: cassandra
ports:
- port: 9042
name: cql
When a StatefulSet pod is rescheduled (e.g., after a node failure), it reattaches to its original PVC by
name, preserving identity and data. Deleting the StatefulSet does NOT automatically delete PVCs — data
is safe until you explicitly kubectl delete pvc.
10. Troubleshooting Storage Issues
Storage problems are among the most common Kubernetes issues. Here is a structured approach to diagnosing and resolving them.
PVC Stuck in Pending
# Step 1: Describe the PVC to get the failure reason
kubectl describe pvc <pvc-name> -n <namespace>
# Look for Events like:
# "no persistent volumes available for this claim and no storage class is set"
# "waiting for first consumer to be created before binding"
# "ProvisioningFailed: InvalidParameterValue: ..."
# Step 2: Check if a StorageClass exists
kubectl get storageclass
# Step 3: Check CSI driver pods are healthy
kubectl get pods -n kube-system -l app.kubernetes.io/name=aws-ebs-csi-driver
kubectl logs -n kube-system -l app=ebs-csi-controller -c csi-provisioner --tail=50
Wrong Access Mode
# Error: "Multi-Attach error for volume — volume is already exclusively attached to one node"
# Cause: EBS (RWO) volume mounted by a second pod on a different node.
# Fix: Use a Deployment with 1 replica, or switch to EFS (RWX).
# Check what access modes the bound PV actually supports
kubectl get pv <pv-name> -o jsonpath='{.spec.accessModes}'
Node Affinity / Topology Conflicts
# Error pod stuck Pending: "node(s) had volume node affinity conflict"
# Cause: EBS volume was created in us-east-1a but pod is scheduled on us-east-1b node.
# Check PV node affinity
kubectl get pv <pv-name> -o yaml | grep -A10 nodeAffinity
# Check which AZ the node is in
kubectl get node <node-name> --show-labels | grep topology
# Fix: Set volumeBindingMode: WaitForFirstConsumer on your StorageClass
# so volumes are always created in the AZ where the pod lands.
Volume Expansion Not Working
# Edit the PVC to request more storage (StorageClass must have allowVolumeExpansion: true)
kubectl patch pvc postgres-data -p '{"spec":{"resources":{"requests":{"storage":"200Gi"}}}}'
# Monitor expansion
kubectl describe pvc postgres-data | grep -E "Capacity|Conditions"
# For filesystem resize to complete, the pod may need to be restarted once
kubectl rollout restart deployment/postgres
For more troubleshooting patterns related to resource limits and pod scheduling, see our Kubernetes Resource Management guide.
11. Storage Capacity and Resource Quotas
In multi-tenant clusters, administrators use ResourceQuota objects to cap how much storage a
namespace can consume — both the number of PVCs and the total bytes requested.
# resourcequota-storage.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: storage-quota
namespace: team-alpha
spec:
hard:
# Maximum number of PVCs in this namespace
persistentvolumeclaims: "10"
# Maximum total storage requested across all PVCs
requests.storage: "500Gi"
# Quotas scoped to a specific StorageClass:
# <storageClassName>.storageclass.storage.k8s.io/requests.storage
gp3.storageclass.storage.k8s.io/requests.storage: "400Gi"
gp3.storageclass.storage.k8s.io/persistentvolumeclaims: "8"
# Prevent use of a specific class entirely (set to "0")
# efs-sc.storageclass.storage.k8s.io/persistentvolumeclaims: "0"
# Apply the quota
kubectl apply -f resourcequota-storage.yaml
# Check current consumption vs quota
kubectl describe resourcequota storage-quota -n team-alpha
# Sample output:
# Name: storage-quota
# Namespace: team-alpha
# Resource Used Hard
# -------- ---- ----
# gp3.storageclass.storage.k8s.io/persistentvolumeclaims 3 8
# gp3.storageclass.storage.k8s.io/requests.storage 150Gi 400Gi
# persistentvolumeclaims 3 10
# requests.storage 150Gi 500Gi
Storage Capacity Tracking (K8s 1.24+ GA)
Kubernetes 1.24 graduated Storage Capacity Tracking to GA. When enabled, the scheduler uses
CSIStorageCapacity objects — published by CSI drivers — to make smarter placement decisions
and avoid scheduling pods onto nodes where the volume cannot be provisioned due to capacity constraints.
# View storage capacity information published by the EBS CSI driver
kubectl get csistoragecapacity -A
# Example output shows capacity per topology zone:
# NAMESPACE NAME STORAGECLASS CAPACITY
# kube-system ...ap-south-1a gp3 12Ti
# kube-system ...ap-south-1b gp3 12Ti
Summary: Storage Decision Flowchart
- Temporary scratch space during job execution? →
emptyDir - Config / secrets as files? →
configMap/secretvolume - Single-writer database (MySQL, Postgres, MongoDB)? → EBS gp3 PVC (RWO)
- Shared read/write across multiple pods/nodes? → EFS PVC (RWX)
- Multiple replicas each need their own volume? → StatefulSet + volumeClaimTemplates
- Backup and restore? → VolumeSnapshot + restore PVC from snapshot
- High-throughput block I/O (Cassandra, etcd)? → EBS io2 PVC with provisioned IOPS
To learn how storage interacts with security policies (e.g., read-only root filesystems, fsGroup), see our Kubernetes Security Best Practices guide. For configuring application settings that complement persistent data, see ConfigMaps and Secrets.
Kubernetes Articles
Quick Reference
Check PVC status:
kubectl get pvc -A
kubectl describe pvc <name>
List StorageClasses:
kubectl get storageclass
List PVs:
kubectl get pv
List snapshots:
kubectl get volumesnapshot -A