How to Deploy a Stateful Application on Kubernetes

How to Deploy a Stateful Application on Kubernetes

For a long time, “Kubernetes is for stateless apps” was conventional wisdom, and running a database on it felt like asking for trouble. That’s changed a lot — StatefulSet, mature storage drivers, and battle-tested operators have made running stateful workloads like databases and message queues on Kubernetes a normal, supportable thing to do. In this guide I’ll walk through deploying a real stateful application (a PostgreSQL-style database) using StatefulSet, persistent storage, and a headless Service, and cover the operational realities that make stateful workloads different from stateless ones.

What Makes Stateful Different

Stateless apps (a typical web API, for example) are interchangeable — any replica can serve any request, and losing one is a non-event. Stateful apps break that assumption:

  • Each replica often needs its own persistent storage, not shared or ephemeral.
  • Replicas frequently need stable, predictable network identities (a database primary needs to be reliably addressable, not randomly assigned a new DNS name every restart).
  • Startup and shutdown order often matters (a replica joining a cluster needs the primary to already be up).
  • Scaling isn’t just “add more identical copies” — it often means reconfiguring cluster membership.

StatefulSet addresses the first two directly; the rest usually needs application-level logic or an operator.

Step 1: Understanding StatefulSet Basics

Unlike a Deployment, a StatefulSet:

  • Assigns each Pod a stable, ordinal name (myapp-0, myapp-1, myapp-2), not a random suffix.
  • Creates Pods in order (0, then 1, then 2) and terminates them in reverse order by default.
  • Pairs with a headless Service to give each Pod a stable DNS name (myapp-0.myapp.default.svc.cluster.local).
  • Uses volumeClaimTemplates to give each Pod its own dedicated PersistentVolumeClaim, which survives Pod rescheduling.

Step 2: Create a Headless Service

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

clusterIP: None is what makes this “headless” — instead of load-balancing, DNS returns the individual Pod IPs directly, which is exactly what you want for addressing a specific replica.

Step 3: Define the StatefulSet

# postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 3
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports:
            - containerPort: 5432
              name: postgres
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: password
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          volumeMounts:
            - name: postgres-storage
              mountPath: /var/lib/postgresql/data
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              cpu: "1"
              memory: 2Gi
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "postgres"]
            initialDelaySeconds: 10
            periodSeconds: 10
  volumeClaimTemplates:
    - metadata:
        name: postgres-storage
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: fast-ssd
        resources:
          requests:
            storage: 20Gi

Create the required Secret first:

kubectl create secret generic postgres-secret --from-literal=password='ChangeMeInProduction!'
kubectl apply -f postgres-headless-service.yaml
kubectl apply -f postgres-statefulset.yaml

Step 4: Watch the Ordered Rollout

kubectl get pods -l app=postgres --watch
NAME         READY   STATUS              RESTARTS   AGE
postgres-0   0/1     ContainerCreating   0          5s
postgres-0   1/1     Running             0          20s
postgres-1   0/1     ContainerCreating   0          22s
postgres-1   1/1     Running             0          40s
postgres-2   0/1     ContainerCreating   0          42s
postgres-2   1/1     Running             0          60s

Notice postgres-1 doesn’t even start creating until postgres-0 is Running and Ready — that ordering guarantee is central to how StatefulSet behaves by default (podManagementPolicy: OrderedReady).

Check the PersistentVolumeClaims created automatically, one per Pod:

kubectl get pvc
NAME                        STATUS   VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS
postgres-storage-postgres-0 Bound    pvc-a1b2c3   20Gi       RWO            fast-ssd
postgres-storage-postgres-1 Bound    pvc-d4e5f6   20Gi       RWO            fast-ssd
postgres-storage-postgres-2 Bound    pvc-g7h8i9   20Gi       RWO            fast-ssd

Step 5: Verify Stable Network Identity

kubectl run debug --rm -it --image=busybox -- nslookup postgres-0.postgres.default.svc.cluster.local
Name:      postgres-0.postgres.default.svc.cluster.local
Address 1: 10.244.1.15

Even if postgres-0 is rescheduled to a different node, it comes back with the same name and reattaches to the same PVC — the identity and the data both persist.

Step 6: Scaling a StatefulSet

kubectl scale statefulset postgres --replicas=5

New Pods (postgres-3, postgres-4) are created in order, each getting a fresh PVC. Note that scaling down does not delete the PVCs by default — Kubernetes leaves them in place intentionally, so scaling back up later reattaches the same data rather than starting fresh. Clean these up manually if you truly want to discard them:

kubectl delete pvc postgres-storage-postgres-4

Step 7: Handling Updates Carefully

StatefulSet supports RollingUpdate by default, updating Pods in reverse ordinal order (highest number first), which for a typical primary-replica database setup means replicas get updated before the primary:

spec:
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 0

The partition field is worth knowing — setting it to a number means only Pods with an ordinal greater than or equal to that number get updated, letting you stage a canary-style update of just the highest-numbered replica before rolling out to the rest.

Step 8: Consider an Operator for Real Production Databases

Hand-rolling a StatefulSet for PostgreSQL, as above, works for learning and simple cases, but for production I’d strongly lean toward a purpose-built operator — something like CloudNativePG, Zalando’s Postgres Operator, or the Bitnami PostgreSQL HA chart — because they handle replication setup, automated failover, backups, and point-in-time recovery, none of which a bare StatefulSet gives you on its own:

helm repo add cnpg https://cloudnative-pg.github.io/charts
helm install cnpg-operator cnpg/cloudnative-pg -n cnpg-system --create-namespace
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: myapp-db
spec:
  instances: 3
  storage:
    size: 20Gi
    storageClass: fast-ssd

This single CR gives you automated primary election, streaming replication, and backup scheduling — things that would take substantial custom logic to build on a raw StatefulSet.

Storage and Disaster Recovery Considerations

  • Use a StorageClass backed by real replicated, durable storage (cloud provider block storage, Ceph, Longhorn) — not local hostPath volumes, which don’t survive node loss.
  • Set reclaimPolicy: Retain on your StorageClass for stateful workloads so accidental PVC deletion doesn’t immediately destroy the underlying volume.
  • Schedule regular backups external to the cluster (e.g., to object storage) — PVC snapshots alone aren’t a full disaster recovery strategy if your entire cluster or cloud region goes down.
  • Test restore procedures regularly; an untested backup is a hypothesis, not a plan.

Common Mistakes

  • Using a Deployment instead of a StatefulSet for anything with per-replica state, then being surprised when a rescheduled Pod loses its data or identity.
  • Forgetting the headless Service, which breaks the stable DNS naming StatefulSet depends on.
  • Assuming scaling down deletes data — it doesn’t, which can be a surprise cost (unused PVCs sitting around) or a lifesaver depending on your expectations.
  • Running a single-replica “StatefulSet” as if it provides high availability — it doesn’t; replication and failover logic still has to come from the app or an operator.

Anti-Affinity for Real Fault Tolerance

Running three replicas of a database means little for availability if all three land on the same physical node, which then has a single point of failure regardless of what the StatefulSet spec claims about replica count. Pod anti-affinity spreads replicas across nodes (and, ideally, availability zones):

spec:
  template:
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchExpressions:
                  - key: app
                    operator: In
                    values: ["postgres"]
              topologyKey: kubernetes.io/hostname
        topologySpreadConstraints:
          - maxSkew: 1
            topologyKey: topology.kubernetes.io/zone
            whenUnsatisfiable: DoNotSchedule
            labelSelector:
              matchLabels:
                app: postgres

Using requiredDuringSchedulingIgnoredDuringExecution here (rather than preferred) means the scheduler will refuse to place two replicas on the same node at all, rather than merely preferring not to — worth the tradeoff of slightly stricter scheduling requirements for genuinely critical stateful workloads.

PodDisruptionBudget for Stateful Workloads

Voluntary disruptions — node drains for maintenance, cluster upgrades, Cluster Autoscaler consolidating nodes — can just as easily take down database replicas as any other workload. A PodDisruptionBudget prevents Kubernetes from voluntarily evicting enough replicas at once to break quorum:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: postgres-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: postgres

With minAvailable: 2 on a 3-replica set, Kubernetes will block a node drain (or any other voluntary disruption) that would drop availability below 2 replicas, forcing whoever’s performing maintenance to wait or address the situation rather than silently taking your database below quorum.

Backup and Restore Workflow in Practice

A StatefulSet (or an operator built on top of one) gives you data durability against Pod rescheduling, but it doesn’t protect against application-level mistakes — an accidental DROP TABLE, a bad migration, ransomware, or a full cluster loss. A practical backup workflow I’ve used for PostgreSQL running via CloudNativePG:

apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: myapp-db-backup
spec:
  schedule: "0 2 * * *"
  backupOwnerReference: self
  cluster:
    name: myapp-db

This schedules a nightly backup to object storage (configured separately on the Cluster resource), independent of the PVC snapshots your cloud provider might also offer. I treat these as complementary, not redundant: PVC snapshots are fast to restore from for routine node-level failures, while object-storage backups (ideally in a different region or account) are what actually protects you against a full cluster or region loss.

Testing the restore path matters as much as the backup itself:

kubectl apply -f restore-cluster.yaml  # points at the backup, spins up a fresh Cluster
kubectl get pods -l cnpg.io/cluster=myapp-db-restored

I schedule a quarterly “restore drill” on any team I work with — actually restoring a backup into a scratch namespace and verifying the data is intact and queryable — because an untested backup strategy has a way of failing exactly when you need it most.

Monitoring Stateful Workloads

Beyond generic Pod health, stateful workloads need domain-specific monitoring — replication lag, connection pool saturation, disk usage trending toward the PVC limit:

pg_replication_lag_seconds > 10
kubelet_volume_stats_used_bytes{persistentvolumeclaim=~"postgres-storage.*"} 
  / kubelet_volume_stats_capacity_bytes{persistentvolumeclaim=~"postgres-storage.*"} > 0.85

The second query — PVC usage climbing above 85% — deserves its own dedicated alert distinct from general resource alerts, since a database that runs out of disk space fails in ways that are considerably more disruptive to recover from than a stateless Pod simply restarting.

Summary

StatefulSet gives Kubernetes the primitives stateful applications need: stable identities, ordered deployment and scaling, and per-replica persistent storage via volumeClaimTemplates. For genuinely production-grade databases, though, pairing StatefulSet concepts with a dedicated operator will save you from reimplementing replication, failover, and backup logic yourself. Either way, understanding how StatefulSet differs from Deployment is the foundation for running any stateful workload reliably on Kubernetes.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Create a Persistent Volume in Kubernetes

How to Create a Persistent Volume in Kubernetes

Next Post
How to Perform Rolling Updates in Kubernetes

How to Perform Rolling Updates in Kubernetes

Related Posts