Kubernetes Entry Created: 10 Apr 2026 Updated: 10 Apr 2026

Kubernetes StatefulSets: Managing Stateful Applications

Overview

Most applications deployed in Kubernetes are stateless: each replica is identical, interchangeable, and holds no local data. If you restart one, another takes its place without any difference to the user. A Deployment is the perfect controller for this kind of workload.

However, some applications are fundamentally stateful. A database cannot simply be replaced by a fresh copy — it owns data that must survive restarts. A distributed cache node has a specific slot range. A message broker replica has its own queue partition. These applications require stable identity, stable storage, and predictable ordering.

Kubernetes addresses this with StatefulSets. A StatefulSet is a workload controller that maintains a sticky identity for each of its Pods. No matter how many times a Pod is rescheduled, it keeps its name, its DNS entry, and its storage volume.

Core Concepts

Stateless vs Stateful — The Fundamental Difference

To understand why StatefulSets exist, it helps to contrast them directly with Deployments:

AspectDeployment (Stateless)StatefulSet (Stateful)
Pod namingRandom suffix: order-api-7f9d4-xk2p8Ordinal index: postgres-0, postgres-1
Pod identityInterchangeable — any Pod can serve any requestUnique — each Pod has a fixed role (e.g., primary vs replica)
StorageShared volume or ephemeral (lost on restart)Each Pod gets its own dedicated PersistentVolumeClaim
DNS recordSingle Service DNS for the whole setIndividual DNS entry per Pod via Headless Service
Scaling orderUnordered — all Pods can start/stop in parallelOrdered — Pods start 0→1→2 and stop 2→1→0
Typical use casesREST APIs, web frontends, microservicesDatabases, message brokers, distributed caches

When Should You Use a StatefulSet?

Use a StatefulSet when your application requires at least one of the following:

  1. Persistent, Pod-specific storage — each instance must own its own data that must not be mixed with another instance's data (e.g., a database replica).
  2. Stable, predictable Pod names — other services or configuration must address a specific Pod by a fixed DNS name (e.g., postgres-0.postgres-headless).
  3. Ordered, graceful deployment and scaling — each Pod must be fully running before the next one starts (e.g., a Raft consensus cluster needs a quorum before adding new members).
  4. Stable network identity across restarts — a Pod that crashes and is rescheduled still gets the same hostname and DNS name.

Common production use cases:

  1. Relational databases: PostgreSQL, MySQL
  2. NoSQL databases: MongoDB, Cassandra, CockroachDB
  3. Message brokers: Apache Kafka, RabbitMQ
  4. Distributed caches: Redis Cluster, Memcached
  5. Search engines: Elasticsearch, OpenSearch
  6. Coordination services: Apache ZooKeeper, etcd

The Headless Service

A StatefulSet requires a Headless Service. A regular Service assigns a virtual ClusterIP and load-balances requests across all Pods. A Headless Service — created by setting clusterIP: None — skips this virtual IP entirely and instead creates an individual DNS A record for each Pod.

This is how each Pod in a StatefulSet gets its own stable DNS address, in the form:

<pod-name>.<headless-service-name>.<namespace>.svc.cluster.local

For example, the primary node of a PostgreSQL StatefulSet named postgres with headless service postgres-headless in the default namespace is reachable at:

postgres-0.postgres-headless.default.svc.cluster.local

This predictable address is what other services — such as your ASP.NET Core API — use to connect directly to the primary database node rather than being randomly load-balanced to any replica.

volumeClaimTemplates — Per-Pod Persistent Storage

In a Deployment, you attach a single PersistentVolumeClaim that all Pods share. In a StatefulSet, you define a volumeClaimTemplates section instead. Kubernetes automatically creates a separate PersistentVolumeClaim for each Pod when it is first scheduled.

For a StatefulSet with 3 replicas and a volume named postgres-data, Kubernetes creates:

  1. postgres-data-postgres-0 — owned exclusively by postgres-0
  2. postgres-data-postgres-1 — owned exclusively by postgres-1
  3. postgres-data-postgres-2 — owned exclusively by postgres-2

Critically, if a Pod is deleted and rescheduled, it re-attaches to the same PVC — the data is never lost even if the Pod moves to a different node.

Ordered Pod Management

By default, StatefulSets use OrderedReady Pod management. This means:

  1. Scale up: Pods are created one at a time in ascending order — pod-0 must be Ready before pod-1 starts.
  2. Scale down: Pods are terminated in descending order — pod-2 is deleted before pod-1.
  3. Updates: The StatefulSet rolls out updates from the highest ordinal backward.

This ordering matters for clustered software. A Kafka broker, for example, needs the first node (the controller) healthy before followers join.

For applications that do not require ordered startup, you can set podManagementPolicy: Parallel to speed up scaling operations.

Update Strategies

StatefulSets support two update strategies:

  1. RollingUpdate (default) — Pods are updated one at a time, highest ordinal first.
  2. OnDelete — Pods are only updated when you manually delete them. Useful when you need full control over the upgrade sequence.

Hands-On: Kubernetes Commands

List StatefulSet Pods (ordered)

StatefulSet Pods always appear with their ordinal suffix. The -l flag filters by label.

kubectl get pods -l app=postgres

Expected output:

NAME READY STATUS RESTARTS AGE
postgres-0 1/1 Running 0 5m
postgres-1 1/1 Running 0 4m
postgres-2 1/1 Running 0 3m

Describe a StatefulSet

Inspect the StatefulSet details including volume claim templates and update strategy:

kubectl describe statefulset postgres

Verify per-Pod PersistentVolumeClaims

Each Pod should have its own PVC. You can confirm this with:

kubectl get pvc -l app=postgres

Expected output:

NAME STATUS VOLUME CAPACITY ACCESS MODES
postgres-data-postgres-0 Bound pvc-a1b2c3 5Gi RWO
postgres-data-postgres-1 Bound pvc-d4e5f6 5Gi RWO
postgres-data-postgres-2 Bound pvc-g7h8i9 5Gi RWO

Resolve a Pod's DNS name from inside the cluster

Test that the headless service DNS records resolve correctly by running a temporary Pod:

kubectl run dns-test --image=busybox:1.37 --restart=Never -it --rm -- \
nslookup postgres-0.postgres-headless

Connect to a specific StatefulSet Pod

Because each Pod has a predictable name, you can open a shell directly into the primary node:

kubectl exec -it postgres-0 -- psql -U orderadmin -d orderdb

Scale a StatefulSet

Scaling up adds new Pods in order. Scaling down removes from the highest ordinal first:

kubectl scale statefulset postgres --replicas=5

Watch the ordered startup

Use -w to watch the ordered creation of Pods in real time:

kubectl get pods -l app=postgres -w

Step-by-Step Example

The Scenario

We will deploy a 3-replica PostgreSQL 16 cluster using a StatefulSet. An ASP.NET Core 10 Order API Deployment will then connect to the primary database node (postgres-0) using the stable DNS name provided by the Headless Service.

Step 1 — Create the Secret for Database Credentials

Never embed credentials directly in a StatefulSet manifest. Store them in a Secret and reference them via environment variables.

apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
type: Opaque
data:
POSTGRES_USER: b3JkZXJhZG1pbg==
POSTGRES_PASSWORD: UEBzc3cwcmQxMjM=
# POSTGRES_USER: orderadmin (base64 encoded)
# POSTGRES_PASSWORD: P@ssw0rd123 (base64 encoded)
kubectl apply -f postgres-secret.yaml

Step 2 — Create the Headless Service

The Headless Service must be created before the StatefulSet. It is what enables the per-Pod DNS records. Setting clusterIP: None is what makes it headless.

apiVersion: v1
kind: Service
metadata:
name: postgres-headless
labels:
app: postgres
spec:
clusterIP: None
selector:
app: postgres
ports:
- name: postgres
port: 5432
targetPort: 5432
kubectl apply -f postgres-headless-service.yaml

Step 3 — Create the StatefulSet

The StatefulSet references the headless service via serviceName. The volumeClaimTemplates section defines the PVC template — Kubernetes will automatically create one PVC per Pod named postgres-data-postgres-0, postgres-data-postgres-1, and postgres-data-postgres-2.

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
labels:
app: postgres
spec:
serviceName: postgres-headless
replicas: 3
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: orderdb
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: postgres-secret
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: POSTGRES_PASSWORD
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "1Gi"
readinessProbe:
exec:
command:
- pg_isready
- -U
- $(POSTGRES_USER)
- -d
- $(POSTGRES_DB)
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 5
livenessProbe:
exec:
command:
- pg_isready
- -U
- $(POSTGRES_USER)
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 3
timeoutSeconds: 5
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
kubectl apply -f postgres-statefulset.yaml

Step 4 — Watch the Ordered Pod Creation

Run this command and observe how Kubernetes creates postgres-0 first and waits for it to become Ready before creating postgres-1, and so on:

kubectl get pods -l app=postgres -w

You will see output like this over approximately 1–2 minutes:

NAME READY STATUS RESTARTS
postgres-0 0/1 ContainerCreating 0
postgres-0 1/1 Running 0
postgres-1 0/1 ContainerCreating 0
postgres-1 1/1 Running 0
postgres-2 0/1 ContainerCreating 0
postgres-2 1/1 Running 0

Step 5 — Deploy the ASP.NET Core Order API

The Order API Deployment connects directly to postgres-0 — the primary node — using its stable DNS name. Because the DNS name is predictable and permanent, the connection string in the environment variable never needs to change, even if the postgres-0 Pod is rescheduled to a different node.

apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
labels:
app: order-api
spec:
replicas: 2
selector:
matchLabels:
app: order-api
template:
metadata:
labels:
app: order-api
spec:
containers:
- name: order-api
image: mcr.microsoft.com/dotnet/aspnet:10.0
ports:
- containerPort: 8080
env:
- name: ConnectionStrings__OrderDb
value: "Host=postgres-0.postgres-headless;Port=5432;Database=orderdb;Username=orderadmin;Password=P@ssw0rd123"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 3
kubectl apply -f order-api-deployment.yaml

Step 6 — Verify the DNS-Based Connection

From inside any Pod in the cluster, verify that the headless service resolves to individual Pod IPs — one DNS record per Pod, not a single load-balanced IP:

kubectl run dns-test --image=busybox:1.37 --restart=Never -it --rm -- \
nslookup postgres-headless

You will see a separate Address line for each of the three PostgreSQL Pods — not a single virtual IP. This confirms the headless service is working correctly.

Step 7 — Simulate a Pod Failure and Verify Data Persistence

Delete postgres-0 and watch Kubernetes recreate it with the same name and the same PVC:

kubectl delete pod postgres-0
kubectl get pods -l app=postgres -w

Notice that the new postgres-0 Pod reattaches to the existing postgres-data-postgres-0 PVC. All data written before the deletion is still there. This is the fundamental guarantee of a StatefulSet.

Step 8 — Verify PVC Retention After Pod Deletion

PVCs created by a StatefulSet are not deleted when the Pod is deleted, and by default they are not deleted even when the StatefulSet itself is deleted. This protects your data from accidental loss.

kubectl get pvc -l app=postgres

To permanently delete the data, you must delete the PVCs manually:

kubectl delete pvc -l app=postgres

Summary

StatefulSets are the right tool whenever your application needs identity, storage, or ordering guarantees that a Deployment cannot provide. Here is a concise summary of everything covered:

  1. A StatefulSet gives each Pod a stable ordinal name (pod-0, pod-1...), a dedicated PersistentVolumeClaim, and a predictable DNS record — all of which survive Pod restarts and rescheduling.
  2. A Headless Service (clusterIP: None) is required. It creates individual DNS A records for each Pod, enabling direct addressing via pod-name.service-name.namespace.svc.cluster.local.
  3. volumeClaimTemplates automatically provision one PersistentVolumeClaim per Pod. PVCs are not deleted when Pods or even the StatefulSet is deleted — data is preserved.
  4. Pods start and stop in strict order by default — essential for clustered systems that require a quorum or a designated primary node.
  5. Use a StatefulSet for databases, message brokers, distributed caches, search engines, and coordination services — anything that holds state unique to each replica.
  6. Use a Deployment for your application tier (APIs, frontends, workers) — these are stateless and benefit from the simpler Deployment controller.

The pattern of pairing a stateless ASP.NET Core Deployment with a stateful PostgreSQL StatefulSet is one of the most common and robust architectures in production Kubernetes environments.


Share this lesson: