The workloads we’ve worked with so far have been disposable and replaceable. This design principle allows Kubernetes to scale, heal, and reschedule workloads seamlessly across the cluster. However, we still need to run workloads that depend on persistence — databases, message queues, and file storage. Running such workloads reliably needs a way to attach durable storage and preserve identity across restarts.
In this topic, we examine how you can run stateful applications on Kubernetes.
PersistentVolumes
Kubernetes provides a layered set of storage primitives that decouple storage from the lifecycle of a Pod and from the underlying infrastructure. It supports many volume types, from local storage to network or cloud storage. We already saw how you can use emptyDir, a temporary directory created when the Pod starts. Unfortunately, data stored here is lost when the Pod is deleted. This is where PersistentVolumes come in.
A PersistentVolume is a piece of storage in the cluster that has been provisioned by an administrator manually or dynamically using a storage provisioner. A PV has a lifecycle independent of any Pod that uses it. It is a cluster-level resource similar to a Node, but for storage.
A PV represents an actual piece of storage somewhere — an NFS share, an AWS EBS volume, a Google Persistent Disk, to name a few. Here's an example of a PV manifest:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-database
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/data
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node-1
- node-2The capacity field defines the size of the storage. accessModes specifies how the volume can be mounted:
ReadWriteOnce(RWO) — mounted by a single node.ReadOnlyMany(ROX) — read-only by many nodes.ReadWriteMany(RWX) — read-write by many nodes.ReadWriteOncePod(RWOP) — mounted as read-write by a single Pod.
persistentVolumeReclaimPolicy defines what happens to the PV when its claim is released. Retain keeps the data, Delete removes it. Each PV has additional specific requirements depending on its storage class. For example, for local-storage, we also need to specify additional node constraints because local storage exists on a specific node.
Usually, cluster administrators provision a PV. As a developer, you only need to request storage using a PersistentVolumeClaim. You can see available PVs using kubectl get persistentvolumes.
PersistentVolumeClaims
Once a persistent volume is available, a request for storage with certain properties can be made using a PersistentVolumeClaim. Where a Pod consumes node resources (CPU and memory), a PVC consumes PV resources. PVCs let you specify size and access modes without worrying about the underlying details of how storage is implemented:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-003
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-storage
resources:
requests:
storage: 2GiWhen you create a PVC, Kubernetes searches for a PV that meets the request and binds them together. Therefore, a PV that can satisfy the PVC request must exist in the cluster for the request to be fulfilled. Once bound, the PVC can be referenced from a Pod just like any other volume, as seen previously.
Both the PVC and the Pod must be in the same namespace. The storageClassName must also match for both PV and PVC, or both must be omitted (denoted as “”).
Dynamic storage provisioning
Manually creating PVs for every claim is tedious and doesn’t scale well. StorageClasses solve this by enabling dynamic provisioning. This means that when a PVC is created, Kubernetes automatically creates a matching PV on demand. A StorageClass describes a type of storage offered by the cluster. It specifies a provisioner (the plugin that creates the underlying storage), parameters specific to that provisioner, and a reclaim policy:
apiVersion: storage.k8s.io/v1
kind: Storageclass
metadata:
name: nfs-provisioner
provisioner: nfs.csi.k8s.io
parameters:
server: <server_ip> # replace with actual values
share: /mnt/<share_name> # replace with actual values
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumerIn the above example, we’re using the NFS CSI Driver for Kubernetes that allows Kubernetes to provide NFS storage:
$ kubectl get storageclasses
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
nfs-provisioner nfs.csi.k8s.io Delete WaitForFirstConsumer false 14mTo use a StorageClass, a PVC references it by name (nfs-provisioner in this case) in the storageClassName field. When you create a PVC that requests storage matching the specified properties, the StorageClass automatically provisions a PV to fulfill the request without manual intervention. With WaitForFirstConsumer, storage will only be provisioned when a Pod that needs it is scheduled. Another mode is Immediate.
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
pvc-2e1a784d-7d4f-4c78-89ea-cdfaeb1fa011 1Gi RWO Delete Bound nfs-claim nfs-provisioner <unset> 10hFor the above setup to work, you must have a valid NFS server.
StatefulSets
Storage solves only half of the stateful workload problem. The other half is identity. Consider a database cluster with three replicas. Each replica has a specific role (primary, secondary), a specific data volume, and a stable network name that your applications use to find it. A Deployment, which treats Pods as interchangeable, isn't suitable here. This is what StatefulSets are designed for.
A StatefulSet provides:
Stable, unique network identifiers. Pods are named predictably as
<statefulset-name>-<ordinal>(for example,mysql-0,mysql-1,mysql-2), and each Pod gets a stable DNS hostname.Stable, persistent storage. Each Pod gets its own PVC, created from a
volumeClaimTemplatessection. When a Pod is rescheduled, it reattaches to the same PVC.Ordered deployment and scaling. Pods are created and terminated in order:
mysql-0comes up beforemysql-1, and they're torn down in reverse.
The example below shows a StatefulSet for a MySQL database:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: mysql-headless # you need a headless service (clusterIP: None) for stable DNS hostnames
replicas: 3
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:9.7.0-oracle
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
value: "hyper2026"
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: dev-sc
resources:
requests:
storage: 1GiNotice the volumeClaimTemplates section: this isn't a single PVC but a template. Kubernetes creates one PVC per Pod (data-mysql-0, data-mysql-1, data-mysql-2), each backed by its own PV. The serviceName field references a headless Service (a service with clusterIP: None) that gives each Pod a stable DNS record like mysql-0.mysql-headless.default.svc.cluster.local.
If mysql-1 is deleted, the StatefulSet recreates a Pod with the same name and reattaches it to data-mysql-1. Deleting a StatefulSet does not delete its PVCs by default — your data is preserved.
Conclusion
We’ve covered the building blocks that Kubernetes provides for running stateful workloads. We’ve seen how PersistentVolumes and PersistentVolumeClaims decouple storage from Pod lifecycles. StorageClasses automate provisioning, eliminating the need to pre-provision storage by hand.
For workloads that need stable identity and per-replica storage, StatefulSets are the preferred controller. They provide ordered deployment, predictable identities, and dedicated PVCs per replica. Together, these primitives let you reap all the benefits of Kubernetes for stateful workloads as well.