Storage: volumes, PersistentVolumes and StorageClasses
How data outlives a pod: the volume types worth knowing, how a claim binds to a volume, and dynamic provisioning by class.
Volumes live with the pod
Containers are disposable and their filesystem disappears with them. A volume is declared on the pod and mounted into one or more of its containers, so data can outlive a single container restart.
spec:
containers:
- name: app
image: ghcr.io/example/app:1.4.0
volumeMounts:
- name: cache
mountPath: /var/cache/app
- name: config
mountPath: /etc/app
readOnly: true
volumes:
- name: cache
emptyDir:
sizeLimit: 500Mi
- name: config
configMap:
name: app-config| Volume type | Lifetime | Use it for |
|---|---|---|
emptyDir | Deleted with the pod | Scratch space, caches between containers in a pod, memory-backed tmpfs |
configMap / secret | Deleted with the pod | Injected configuration; updates propagate to the file |
hostPath | Lifetime of the node | Node agents and local testing. Avoid: it couples a pod to one machine |
persistentVolumeClaim | Independent of the pod | Anything that must survive a reschedule: databases, uploads |
projected | Deleted with the pod | Combining service account token, configMap and secret into one mount |
csi | Depends on the driver | Cloud disks, NFS, object store gateways, snapshots |
Mounted ConfigMaps and Secrets are read-only by default and a mount path replaces the directory contents entirely. That is why apps that expect a directory they can also write to need an emptyDir beside the config mount.
PersistentVolumes, claims and binding
A PersistentVolume is real storage in the cluster, cluster-scoped. A PersistentVolumeClaim is a namespaced request for some of it. A control loop binds one to the other, and the pair is one-to-one until the claim is released.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pgdata
namespace: storefront-prod
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources:
requests:
storage: 50Gi
---
apiVersion: v1
kind: Pod
metadata:
name: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: pgdata- State
Pendingmeans no volume matched: wrong storage class, capacity larger than any volume, or an access mode the backend cannot offer. - A claim binds to a volume at least as large as the request. You get the whole volume, not a slice of it.
- Deleting a claim does not necessarily free the data — that is the reclaim policy of the volume, and
Retainkeeps it around, detached. - Storage classes are cluster-scoped, so a claim names a class rather than a volume. That indirection is what makes manifests portable between clusters.
| Access mode | Short | Meaning |
|---|---|---|
ReadWriteOnce | RWO | One node may mount it read-write. The usual default, and a rolling update can stall on it |
ReadOnlyMany | ROX | Many nodes read-only |
ReadWriteMany | RWX | Many nodes read-write. Needs NFS or a shared filesystem; block storage cannot do it |
ReadWriteOncePod | RWOP | Exactly one pod cluster-wide. The safe choice for a single-writer database |
StorageClasses and StatefulSets
A StorageClass names a provisioner plus the parameters it should use. With it, a claim provisions real storage on demand instead of waiting for an administrator to create a volume.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
reclaimPolicy: Delete # Delete | Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
parameters:
type: gp3
encrypted: "true"
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres # a headless Service gives each pod a DNS name
replicas: 3
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates: # one PVC per pod, created and kept
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources:
requests:
storage: 50GiWaitForFirstConsumerdelays provisioning until a pod is scheduled, so the disk lands in the right zone. It is almost always what you want.- Set one class as the default with the
storageclass.kubernetes.io/is-default-classannotation so unqualified claims still work. - StatefulSet PVCs are not deleted when the StatefulSet or the pods are deleted. That is deliberate, and it is why cleaning up a database takes two extra commands.
- Storage can be expanded if the class allows it, but a claim can never be shrunk. Resize means a new claim and a copy.
kubectl get storageclass
kubectl get pv,pvc -n storefront-prod
kubectl describe pvc pgdata -n storefront-prod # events explain Pending
kubectl get pvc pgdata -o jsonpath='{.spec.resources.requests.storage}'Delete and Retain the hard way.FAQ
My PVC is stuck Pending, what now?
kubectl describe pvc events. Usual causes are a StorageClass that does not exist, a provisioner that is not running, or an access mode such as RWX that the backend cannot provide.How do I back up a volume?
VolumeSnapshot) for whole-disk copies, plus logical backups from the application itself. A snapshot of a running database filesystem is not automatically a consistent backup.Related
StatefulSets, DaemonSets and Jobs Resource requests, limits and autoscaling
Last refreshed 2026-09-18.