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.
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).
- Docker Desktop or Engine, running
- kind v0.32.0 and kubectl v1.36.x — on macOS:
brew install kind kubectl; on Linux, grab the binaries from the kind quick start and kubectl install docs - macOS or Linux (Windows works fine under WSL2)
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/postgresql — not /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: Deleteis handy in dev clusters sokubectl delete -fcleans up PVCs too. The defaultRetainis right for production. - Don't scale this to
replicas: 3expecting 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
storageClassNamefor 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
- StatefulSets — kubernetes.io
- kind Quick Start — kind.sigs.k8s.io
- kind Releases — github.com
- postgres Official Image — hub.docker.com
- PostgreSQL Versioning Policy — postgresql.org
- Kubernetes Releases — kubernetes.io
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
No comments yet
Be the first to weigh in.