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 typeLifetimeUse it for
emptyDirDeleted with the podScratch space, caches between containers in a pod, memory-backed tmpfs
configMap / secretDeleted with the podInjected configuration; updates propagate to the file
hostPathLifetime of the nodeNode agents and local testing. Avoid: it couples a pod to one machine
persistentVolumeClaimIndependent of the podAnything that must survive a reschedule: databases, uploads
projectedDeleted with the podCombining service account token, configMap and secret into one mount
csiDepends on the driverCloud 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 Pending means 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 Retain keeps 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 modeShortMeaning
ReadWriteOnceRWOOne node may mount it read-write. The usual default, and a rolling update can stall on it
ReadOnlyManyROXMany nodes read-only
ReadWriteManyRWXMany nodes read-write. Needs NFS or a shared filesystem; block storage cannot do it
ReadWriteOncePodRWOPExactly 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: 50Gi
  • WaitForFirstConsumer delays 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-class annotation 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}'
⚠️
A PVC expands but never shrinks, and the underlying disk may be impossible to shrink even if the object says otherwise. Size up deliberately, and before deleting a claim confirm its volume's reclaim policy, or you will discover the difference between Delete and Retain the hard way.

FAQ

My PVC is stuck Pending, what now?
Read 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?
Snapshots through the CSI driver (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.

StatefulSets, DaemonSets and Jobs Resource requests, limits and autoscaling

Last refreshed 2026-09-18.