Skip to content
Cloud & Infra Intermediate Tutorial

Run a Stateful App on Kubernetes with StatefulSets and PVCs

Move PostgreSQL onto Kubernetes with StatefulSets, persistent volumes, and a headless service - no data loss on restarts.

Emeka Okafor
Emeka Okafor
Security Editor · Aug 15, 2026 · 5 min read
Run a Stateful App on Kubernetes with StatefulSets and PVCs

What you'll build

You'll move a PostgreSQL database onto a local Kubernetes cluster the correct way: a StatefulSet with a volume claim template and a headless service, then prove the pod keeps both its data and its DNS identity when it's killed. The same manifests carry over to a real cluster with a different storage class.

Prerequisites

Verified in August 2026 against Kubernetes v1.36.1, kind v0.32.0, kubectl v1.36, and the postgres:18 image (PostgreSQL 18.6).

1. Create a local cluster and check its storage class

kind create cluster --name pgdemo
kubectl get storageclass
NAME                 PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
standard (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  40s

kind ships a default StorageClass named standard backed by the local-path provisioner. Note WaitForFirstConsumer: PVCs will sit in Pending until a pod actually needs them — that's by design, not a failure.

2. Put the database password in a Secret

kubectl create secret generic postgres-secret \
  --from-literal=POSTGRES_PASSWORD='s3cure-pw'

The official PostgreSQL image refuses to start without POSTGRES_PASSWORD, and a Secret keeps it out of your manifests.

3. Define the headless Service and StatefulSet

Save this as postgres.yaml:

apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  clusterIP: None          # headless: gives each pod a stable DNS name
  selector:
    app: postgres
  ports:
  - port: 5432
    name: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres    # must reference the headless Service above
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      terminationGracePeriodSeconds: 30
      containers:
      - name: postgres
        image: postgres:18
        ports:
        - containerPort: 5432
          name: postgres
        env:
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: postgres-secret
              key: POSTGRES_PASSWORD
        readinessProbe:
          exec:
            command: ["pg_isready", "-U", "postgres"]
          initialDelaySeconds: 5
          periodSeconds: 5
        volumeMounts:
        - name: data
          mountPath: /var/lib/postgresql
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: standard
      resources:
        requests:
          storage: 1Gi

Three things matter here. The headless Service (clusterIP: None) plus serviceName is what gives the pod a stable DNS name, postgres-0.postgres.default.svc.cluster.local, instead of a load-balanced VIP. The volumeClaimTemplates block stamps out one PVC per pod (data-postgres-0) that survives pod deletion and always reattaches to the same ordinal. And the mount path is /var/lib/postgresqlnot /var/lib/postgresql/data. The postgres:18 image moved its volume up a level (default PGDATA is now /var/lib/postgresql/18/docker, which enables pg_upgrade --link across major versions); the old path you'll see in pre-18 tutorials breaks persistence on this image.

4. Apply and watch it come up

kubectl apply -f postgres.yaml
kubectl rollout status statefulset/postgres
service/postgres created
statefulset.apps/postgres created
statefulset rolling update complete 1 pods at revision postgres-6d7f8b9c4d...

Verify it works

Confirm the pod and its claim, then write a row:

kubectl get pods,pvc
kubectl exec -it postgres-0 -- psql -U postgres \
  -c "CREATE TABLE apps (id serial PRIMARY KEY, name text); INSERT INTO apps (name) VALUES ('orders-db');"

Now kill the pod and watch the StatefulSet controller rebuild it with the same name and the same volume:

kubectl delete pod postgres-0
kubectl get pods -w
NAME         READY   STATUS              RESTARTS   AGE
postgres-0   0/1     ContainerCreating   0          2s
postgres-0   1/1     Running             0          9s

A Deployment would have spawned postgres-7f9d...-x2kqp with an empty volume. Here it's postgres-0 again, bound to the same data-postgres-0 PVC. Prove the data survived, and that the stable DNS name resolves from another pod:

kubectl exec postgres-0 -- psql -U postgres -c "SELECT * FROM apps;"
kubectl run pg-client --rm -it --restart=Never --image=postgres:18 \
  --env=PGPASSWORD='s3cure-pw' -- \
  psql -h postgres-0.postgres.default.svc.cluster.local -U postgres -c '\conninfo'
 id |   name
----+-----------
  1 | orders-db
(1 row)

You are connected to database "postgres" as user "postgres" on host
"postgres-0.postgres.default.svc.cluster.local" (address "10.244.0.7") at port "5432".

That's the whole contract: your app connects to postgres-0.postgres and never notices restarts.

Troubleshooting

PVC stuck in Pending. Run kubectl describe pvc data-postgres-0. If the event says waiting for first consumer to be created before binding, that's normal WaitForFirstConsumer behavior — it binds when the pod schedules. If it says storageclass.storage.k8s.io "fast" not found, your storageClassName doesn't exist in this cluster; check kubectl get storageclass and use standard on kind (or delete the line to use the cluster default).

spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden. You edited an immutable field — usually volumeClaimTemplates. Recreate the object without touching the pods or data: kubectl delete statefulset postgres --cascade=orphan, then kubectl apply -f postgres.yaml. Note this won't resize existing PVCs; expand those directly on the PVC if your storage class allows it.

Data disappears on restart, or the container exits on boot complaining about existing data. You mounted the volume at /var/lib/postgresql/data. That was correct through PostgreSQL 17, but on postgres:18 the data lands outside your volume, so every restart starts empty — and reusing a 17-era volume there makes the 18 entrypoint bail because the data sits in a location the server no longer uses. Mount at /var/lib/postgresql as in step 3.

Next steps

  • Add persistentVolumeClaimRetentionPolicy (stable since Kubernetes v1.32) to your spec — whenDeleted: Delete is handy in dev clusters so kubectl delete -f cleans up PVCs too. The default Retain is right for production.
  • Don't scale this to replicas: 3 expecting a Postgres cluster — each replica gets its own empty volume and they don't replicate. For real HA Postgres on Kubernetes, use an operator like CloudNativePG, which manages streaming replication and failover on top of these same primitives.
  • Moving to a managed cluster? Swap storageClassName for your CSI-backed class (EBS on EKS, Persistent Disk on GKE) — the rest of the manifest is unchanged.
  • Read the StatefulSets concepts page for ordered rollouts, partitioned updates, and ordinal options.

Sources & further reading

  1. StatefulSets — kubernetes.io
  2. kind Quick Start — kind.sigs.k8s.io
  3. kind Releases — github.com
  4. postgres Official Image — hub.docker.com
  5. PostgreSQL Versioning Policy — postgresql.org
  6. Kubernetes Releases — kubernetes.io
Emeka Okafor
Written by
Emeka Okafor · Security Editor

Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading