Kubernetes
StatefulSet & Storage

Published: October 30, 2026 · 11 min read

Kubernetes StatefulSet and Storage Explained Simply

A web app can be thrown away and recreated. A database cannot.

Running a database on Kubernetes needs six objects you have not met yet. Here they all are, in the order you will meet them.

Here is the short version:

  • A database has three parts. Only the data files need a real disk.
  • A PersistentVolume is the real disk.
  • A PersistentVolumeClaim is your request for one.
  • A StorageClass is the rule that creates it.
  • A headless Service gives each Pod its own DNS name.
  • A StatefulSet keeps the name and the disk together.
  • A DaemonSet runs one Pod on every node.

How Kubernetes storage objects connect, from a Pod volume mount to the PersistentVolume

StatefulSet — identity that survives

A StatefulSet is for Pods that must keep their identity, not be thrown away and replaced by a lookalike. A database replica is the normal example.

A Kubernetes StatefulSet keeps stable pod names and one claim per pod, unlike a Deployment

A regular Deployment treats every Pod as identical. Kill one, a new one appears with a random name, and nobody notices the difference. A StatefulSet refuses to do that.

DeploymentStatefulSet
Pod namesrandom — app-7d4b-2kq9xfixed — mongo-0, mongo-1
Start orderall at onceone at a time, in order
Delete orderanyreverse order
Storageone shared claimone claim per Pod

How creation behaves: ask for 3 replicas and mongo-0 is created first. Kubernetes waits for it to be ready, then creates mongo-1, waits again, then mongo-2. Each Pod gets the exact same fixed name every time, not a random suffix.

statefulset.yaml
# statefulset.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mongo
  namespace: hotel
spec:
  serviceName: mongo           # the headless Service above
  replicas: 3
  selector:
    matchLabels:
      app: mongo
  template:
    metadata:
      labels:
        app: mongo
    spec:
      containers:
        - name: mongo
          image: mongo:7.0
          ports:
            - containerPort: 27017
          volumeMounts:
            - name: data
              mountPath: /data/db

  volumeClaimTemplates:        # NOT volumes - a PVC per Pod
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 5Gi

The field that makes this happen is volumeClaimTemplates. It is a template, not a single volume. Kubernetes stamps out one PersistentVolumeClaim per Pod from it, so mongo-0 and mongo-1 never share a disk.

terminal — what you get
kubectl apply -f statefulset.yaml

kubectl get pods -n hotel
# NAME      READY   STATUS    RESTARTS   AGE
# mongo-0   1/1     Running   0          90s
# mongo-1   1/1     Running   0          60s
# mongo-2   1/1     Running   0          30s
# names are fixed, and they started in order

kubectl get pvc -n hotel
# NAME           STATUS   VOLUME        CAPACITY   STORAGECLASS
# data-mongo-0   Bound    pvc-9f2c1a4b  5Gi        gp3
# data-mongo-1   Bound    pvc-3a7e2c91  5Gi        gp3
# data-mongo-2   Bound    pvc-5d1b8f44  5Gi        gp3
# one claim per Pod, named <template>-<pod>

Look at the claim names: data-mongo-0, data-mongo-1, data-mongo-2. The pattern is <template name>-<pod name> — one claim per Pod, matched by name.

How deletion behaves: delete mongo-0 and Kubernetes recreates it with the same name. It reattaches the same claim, so the Pod comes back with its old data already there. Scale the whole StatefulSet down and Pods are removed in reverse order — the highest number goes first.

terminal — delete and watch
# delete mongo-0 and watch

kubectl delete pod mongo-0 -n hotel
# pod "mongo-0" deleted

kubectl get pods -n hotel
# NAME      READY   STATUS    RESTARTS   AGE
# mongo-0   1/1     Running   0          8s     <-- same name
# mongo-1   1/1     Running   0          6m
# mongo-2   1/1     Running   0          5m

kubectl get pvc -n hotel | grep mongo-0
# data-mongo-0   Bound   pvc-9f2c1a4b   5Gi   gp3
# same claim, same volume, same data

Same name back. Same claim. Same data. That is the whole point.

🚫

Deleting a StatefulSet does not delete its PVCs. That is on purpose, so your data survives a mistake. Delete them yourself with kubectl delete pvc when you really mean it.

DaemonSet — one Pod per node

A DaemonSet has a different purpose entirely: run exactly one Pod on every node in the cluster. Not a count you choose — the node count decides it.

A Kubernetes DaemonSet runs exactly one pod on every node in the cluster

You use it for anything that must touch every machine, not just a few:

  • log collectors reading each node's own /var/log
  • metrics agents
  • CNI network plugins
  • the EBS CSI node pod from the storage setup
daemonset.yaml
# daemonset.yaml  -  notice there is no replicas field

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: log-agent
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: log-agent
  template:
    metadata:
      labels:
        app: log-agent
    spec:
      containers:
        - name: log-agent
          image: fluent/fluent-bit:3.0
          volumeMounts:
            - name: varlog
              mountPath: /var/log
              readOnly: true
      volumes:
        - name: varlog
          hostPath:            # reads the NODE's own disk
            path: /var/log

There is no replicas field. The node count is the replica count. That is the one thing to remember.

How it behaves: add a node to the cluster tomorrow. The DaemonSet's Pod appears on it automatically, with no action from you. Remove a node and its Pod is simply gone with it. Deletion works the same as any controller. Delete the DaemonSet and every one of its Pods, on every node, goes with it.

Headless Service — a name for every Pod

A normal Service gives you one address and picks a Pod at random. For a web app that is exactly right.

A Kubernetes headless Service gives every pod its own DNS name instead of one shared address

For a StatefulSet it is useless, because each Pod there is a distinct member, not an interchangeable one. You need to reach mongo-0 specifically, not whichever Pod the Service feels like picking.

headless-service.yaml
# headless-service.yaml

apiVersion: v1
kind: Service
metadata:
  name: mongo
  namespace: hotel
spec:
  clusterIP: None              # <-- this one line makes it headless
  selector:
    app: mongo
  ports:
    - port: 27017
      targetPort: 27017

# DNS now returns every Pod IP, not one service IP:
#   mongo-0.mongo.hotel.svc.cluster.local
#   mongo-1.mongo.hotel.svc.cluster.local
#   mongo-2.mongo.hotel.svc.cluster.local

One line does it: clusterIP: None. That single line turns off the "pick one address" behaviour.

Normal ServiceHeadless Service
clusterIPan IP addressNone
DNS returnsthe one service IPevery Pod IP
You reacha random Podmongo-0, mongo-1, by name

A StatefulSet's serviceName field points at a headless Service like this one. That is how each of its Pods gets its own stable DNS name.

Persistent Volumes — where the data actually lives

Step back to the thing a database needs underneath all of this. Somewhere its data files physically survive.

A database management system is the software that stores your data and answers questions about it. MongoDB, MySQL, PostgreSQL. They all work the same way inside.

The three parts inside a Kubernetes database system, engine, config and data files

PartWhat it isIf the Pod dies
The enginethe running process that answers queriescomes back on its own
The configport, users, limits — a ConfigMap or Secretcomes back on its own
The data filesthe real rows, under /data/dbgone forever

That third row is the entire problem.

🚫

A container's filesystem dies with the container. Write your database files there and one restart wipes every record.

The fix is to put the data files on a disk that lives outside the Pod. That disk is a PersistentVolume, and it is requested and provisioned through three objects.

The three Kubernetes storage objects, StorageClass, PersistentVolumeClaim and PersistentVolume

PersistentVolume — the disk

A PV is a real piece of storage in the cluster. On EKS it is an AWS EBS disk.

It is a cluster level object. It does not belong to any Pod, and deleting a Pod does not delete it.

pv.yaml
# pv.yaml  -  the disk itself (you rarely write this by hand)

apiVersion: v1
kind: PersistentVolume
metadata:
  name: mongo-pv
spec:
  capacity:
    storage: 5Gi
  accessModes:
    - ReadWriteOnce            # one node may mount it at a time
  storageClassName: gp3
  persistentVolumeReclaimPolicy: Delete

You will almost never write this file. Read it once to see what a PV holds, then let the StorageClass create them for you.

PersistentVolumeClaim — the request

A PVC is you asking for a disk. You say how much and what kind. You never say which disk.

pvc.yaml
# pvc.yaml  -  the request

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mongo-pvc
  namespace: hotel
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: gp3        # leave out to use the default
  resources:
    requests:
      storage: 5Gi             # "I need 5Gi"

Think of it as a ticket. You hand in the ticket, Kubernetes hands back a volume. This is exactly what a StatefulSet's volumeClaimTemplates stamps out automatically, one per Pod.

ObjectWho writes itScope
PersistentVolumeKubernetes, usuallythe whole cluster
PersistentVolumeClaimyou (or a StatefulSet, on your behalf)one namespace

StorageClass — the rule

A StorageClass answers two questions: who creates the disk, and what kind is it.

storageclass.yaml
# storageclass.yaml  -  the rule

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com   # who creates the disk
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
parameters:
  type: gp3                    # what kind of disk
FieldMeaning
provisionerthe driver that does the work
parameters.typethe disk type, here a gp3 SSD
reclaimPolicyDelete removes the real disk with the claim
volumeBindingModeWaitForFirstConsumer waits for a Pod before creating it

Mark one as default and every PVC that names no class gets it automatically.

How a Pod actually uses it

Two blocks, and the names between them must match.

deployment.yaml
# how a Pod actually uses the claim

spec:
  containers:
    - name: mongo
      image: mongo:7.0
      volumeMounts:            # 1. where it appears inside
        - name: mongo-data
          mountPath: /data/db

  volumes:                     # 2. where it comes from
    - name: mongo-data
      persistentVolumeClaim:
        claimName: mongo-pvc

# the two "name: mongo-data" lines must match
  • volumes says where the storage comes from — a claim.
  • volumeMounts says where it appears inside the container — a path.
⚠️

volumes[].name and volumeMounts[].name must be the same word. Mistype one and the container starts with an empty folder.

The Three Controllers Side by Side

DeploymentStatefulSetDaemonSet
How many Podsreplicasreplicasone per node
Pod namesrandomfixed and numberedrandom
Own storagenoyes, one eachusually a hostPath
Use it forweb apps, APIsdatabases, queuesagents on every node

Quick Reference

commands
# storage
kubectl get storageclass
kubectl get pvc -n <namespace>
kubectl get pv
kubectl describe pvc <name> -n <namespace>

# stateful workloads
kubectl get statefulset -n <namespace>
kubectl get pods -n <namespace> -l app=mongo
kubectl describe sts <name> -n <namespace>
kubectl scale sts <name> --replicas=5 -n <namespace>

# headless service DNS
kubectl exec -it <pod> -n <ns> -- nslookup mongo
kubectl get svc <name> -n <namespace>          # CLUSTER-IP shows None

# daemonset
kubectl get daemonset -A
kubectl get pods -o wide -n <ns>               # one per node

# delete a StatefulSet but KEEP the data
kubectl delete sts <name> -n <ns> --cascade=orphan
CommandWhat it shows
kubectl get pvc -n <ns>your claims, Pending or Bound
kubectl get pvthe real disks that exist
kubectl get storageclasswhich one is (default)
kubectl get sts -n <ns>StatefulSets and their ready count
kubectl get svc <name>CLUSTER-IP is None if headless
kubectl get daemonset -Aone Pod per node, everywhere
kubectl delete sts <name> --cascade=orphanremove the controller, keep the Pods

What's Next

Six objects, one job: keep the data when everything else is replaced. The official StatefulSet page (opens in a new tab) and the persistent volumes page (opens in a new tab) go deeper on each.

← Back to EKS Storage Setup


Frequently Asked Questions

What is a database management system?

A database management system is the software that stores your data and answers questions about it. MongoDB, MySQL and PostgreSQL are all examples. It has three parts. The engine is the running process. The config holds the port and the users. The data files are the actual rows on disk. Only the data files have to survive a restart.

What is a PersistentVolume in Kubernetes?

A PersistentVolume is a real piece of storage in the cluster, such as an AWS EBS disk. It exists on its own, separate from any Pod, so deleting a Pod does not delete it. You rarely write one by hand. A StorageClass creates it for you when a claim asks for storage.

What is the difference between a PersistentVolume and a PersistentVolumeClaim?

A PersistentVolume is the actual disk. A PersistentVolumeClaim is a request for one. You write the claim saying how much space you need. Kubernetes finds or creates a volume that matches, then binds the two together. Your Pod only ever names the claim, never the volume.

What is a StorageClass in Kubernetes?

A StorageClass is a rule that says which driver creates a disk and what kind of disk it is. On EKS the provisioner is ebs.csi.aws.com and the type might be gp3. Mark one as the default and any claim that names no StorageClass quietly uses it. That keeps your YAML free of cloud specific names.

What is a headless Service in Kubernetes?

A headless Service is one with clusterIP set to None. A normal Service gives you one address and hides the Pods behind it. A headless Service gives no address of its own. DNS returns every Pod instead, each with a stable name such as mongo-0.mongo. That is how you reach one specific database member rather than a random one.

What is a StatefulSet in Kubernetes?

A StatefulSet runs Pods that must keep their identity. Each Pod gets a fixed name ending in a number, such as mongo-0 and mongo-1. Each gets its own PersistentVolumeClaim from the volumeClaimTemplates block. They are created in order and deleted in reverse. Delete mongo-0 and it comes back as mongo-0, with the same disk.

What is the difference between a StatefulSet and a Deployment?

A Deployment treats every Pod as interchangeable. Names are random, order does not matter, and one claim is shared by all of them. A StatefulSet treats each Pod as a distinct member. Names are fixed and numbered, Pods start in order, and each one gets its own claim. Use a Deployment for an app and a StatefulSet for a database.

What is a DaemonSet in Kubernetes?

A DaemonSet runs exactly one Pod on every node. You never set a replicas number, because the node count is the replica count. Add a node tomorrow and it gets its Pod automatically. It is used for things that must touch every machine. Log collectors, metrics agents, CNI plugins and the EBS CSI node pod.