PostgreSQL on Kubernetes gets tricky once storage, pod identity, and recovery matter. This setup uses a StatefulSet, stable DNS, Kubernetes Secrets, and a persistent block volume.
You’ll build one PostgreSQL instance whose name and data survive pod replacement. It fits a homelab, development cluster, or small internal service. If you need failover, replication, scheduled backups, or controlled upgrades, use CloudNativePG.
What Is PostgreSQL on a Kubernetes StatefulSet?
PostgreSQL is an open-source relational database. A Kubernetes StatefulSet manages pods that need fixed identities and their own storage.
A Deployment gives pods disposable names. It can attach storage, but it doesn’t keep an ordinal identity such as postgres-0. A StatefulSet provides:
- A fixed pod name tied to the workload.
- A dedicated PersistentVolumeClaim (PVC) for each pod ordinal.
- Ordered creation, updates, and termination.
- Stable per-pod DNS when paired with a headless Service.
PostgreSQL must reconnect to the correct volume after rescheduling. A standard Deployment is a poor fit for database nodes. It’s still the usual choice for stateless applications.
A StatefulSet doesn’t configure PostgreSQL replication. Setting replicas: 3 creates three separate databases unless you configure replication yourself. That’s an unpleasant surprise. This setup uses one replica on purpose.
Prerequisites
Make sure you have:
- An existing Kubernetes 1.30 or later cluster.
- Permission to create namespaces, Services, Secrets, ConfigMaps, StatefulSets, pods, and PVCs.
- A Container Storage Interface (CSI) driver that can create block volumes.
- A storage class that supports
ReadWriteOncevolumes. kubectl1.30 or later, within one minor version of the Kubernetes API server.- macOS 12 or later for the local CLI workflow.
- At least 2 CPU cores, 4 GB of cluster memory, and 10 GB of free persistent storage.
- A browser for checking the public CloudNativePG documentation.
- A local directory for the manifests.
- A backup destination outside the PostgreSQL PVC.
This setup was tested with a Kubernetes 1.30-compatible cluster, kubectl 1.30, PostgreSQL 17.5, and CSI-backed ReadWriteOnce storage. Patch releases generally preserve compatibility, but that isn’t a guaranteed promise from upstream vendors: PostgreSQL patch releases can change Docker image tags, and Kubernetes behavior can evolve between patch versions. Treat later Kubernetes 1.x and PostgreSQL 17.x patch releases as likely, not certain, to work the same way, and verify each one. Test image updates before they reach a live database.
Important: This tutorial assumes the cluster already exists. It doesn’t cover cloud setup wizards or managed database services.
Step-by-Step Guide
Step 1: Install kubectl and verify cluster access
macOS
Install kubectl with Homebrew:
brew install kubectl
Check the client version:
kubectl version --client
Expected output resembles:
Client Version: v1.30.x
Kustomize Version: v5.x.x
Your cluster administrator should provide a kubeconfig. The default path on macOS is:
$HOME/.kube/config
If the file is elsewhere, point kubectl to it for this terminal session:
export KUBECONFIG="/full/path/to/your/kubeconfig"
List the cluster nodes:
kubectl get nodes
Expected output:
NAME STATUS ROLES AGE VERSION
k8s-control Ready control-plane 42d v1.30.x
k8s-worker-1 Ready worker 42d v1.30.x
Every node that runs workloads should report Ready. Fix failed nodes first. PostgreSQL won’t improve a broken scheduler.


Web-based cluster shell
Some self-hosted cluster platforms provide a browser terminal. Open the cluster page, select the target cluster, and find its Terminal, Shell, or kubectl Shell tool.
Run:
kubectl get nodes
The result should show the same cluster and Ready nodes as the macOS workflow. Check the active context before changing anything:
kubectl config current-context
Expected output:
homelab-cluster
Menu names vary by cluster interface. If the shell lacks an authenticated kubeconfig, use the macOS terminal. Fighting a half-configured web console rarely pays.

Step 2: Confirm that persistent storage is available
List the cluster’s storage classes:
kubectl get storageclass
Expected output:
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
longhorn (default) driver.longhorn.io Delete Immediate
local-path rancher.io/local-path Delete WaitForFirstConsumer

Record the class you plan to use. The examples use longhorn; replace it with your storage class.
Inspect the class before creating the database:
kubectl describe storageclass longhorn
Check these points:
Provisionernames an installed and healthy CSI provisioner.VolumeBindingModeis eitherImmediateorWaitForFirstConsumer.- The class can provide
ReadWriteOncestorage. - The backing storage has at least 10 GB free.
With WaitForFirstConsumer, the PVC may stay Pending until Kubernetes schedules postgres-0. That’s expected. The scheduler needs a suitable node before the CSI driver creates the volume.
A class marked (default) isn’t always the right choice. Local-path provisioners often bind data to one node. Network-backed block storage gives the scheduler more recovery options. The trade-off is that each database read crosses the network. Measure the latency; the storage layer isn’t free.
Step 3: Create a namespace and working directory
On macOS, create a local directory for the YAML files:
mkdir -p "$HOME/postgres-kubernetes"
cd "$HOME/postgres-kubernetes"
Create a dedicated namespace:
kubectl create namespace postgres
Expected output:
namespace/postgres created
Set it as the default namespace for the current context:
kubectl config set-context --current --namespace=postgres
Expected output:
Context “homelab-cluster” modified.
Confirm it:
kubectl config view --minify -o jsonpath='{..namespace}'
Expected output:
postgres
A separate namespace keeps names, access rules, and backup jobs manageable. It also lowers the chance of deleting the wrong postgres object at 02:00.
Step 4: Create the headless Service
A normal Kubernetes Service has a virtual ClusterIP and balances connections across matching pods. A headless Service sets clusterIP: None. Cluster DNS then returns each pod address.
That gives postgres-0 this stable DNS name:
postgres-0.postgres.postgres.svc.cluster.local
The first postgres is the Service name. The second is the namespace.
Create headless-service.yaml in $HOME/postgres-kubernetes:
# $HOME/postgres-kubernetes/headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: postgres
labels:
app.kubernetes.io/name: postgres
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app.kubernetes.io/name: postgres
ports:
- name: postgres
port: 5432
targetPort: postgres
publishNotReadyAddresses: true adds the pod to DNS before its readiness probe passes. Stateful cluster software may need that early identity. This one-node setup doesn’t depend on it, but the setting makes the Service’s role clear.

Apply the manifest:
kubectl apply -f "$HOME/postgres-kubernetes/headless-service.yaml"
Expected output:
service/postgres created

Verify that the Service is headless:
kubectl get service postgres
Expected output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
postgres ClusterIP None <none> 5432/TCP 10s
CLUSTER-IP should be None. A normal IP means you applied a different Service definition.
Step 5: Store credentials in a Secret
Don’t put a real database password in a YAML file or Git repository. Create the Secret from terminal input. This keeps the value out of the working directory.
Set the username and database name:
POSTGRES_USER="postgres-admin"
POSTGRES_DB="appdb"
Read the password without showing it:
read -s -p "Enter a strong PostgreSQL password: " POSTGRES_PASSWORD
Press Enter after typing the password. On macOS, hidden input leaves the cursor on the prompt line. That’s normal, though slightly unfriendly.
Create the Secret:
kubectl create secret generic postgres-credentials \
--from-literal=POSTGRES_USER="$POSTGRES_USER" \
--from-literal=POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
--from-literal=POSTGRES_DB="$POSTGRES_DB"
The backslashes continue one command across several lines. Expected output:
secret/postgres-credentials created
Remove the password from the current shell:
unset POSTGRES_PASSWORD

Confirm the keys exist without decoding them:
kubectl describe secret postgres-credentials
Expected output:
Name: postgres-credentials
Namespace: postgres
Type: OpaqueData
====
POSTGRES_DB: 5 bytes
POSTGRES_PASSWORD: 24 bytes
POSTGRES_USER: 14 bytes
Kubernetes Secrets use base64 encoding. Kubernetes doesn’t encrypt them at rest by default. Enable API encryption and limit Secret access with role-based access control (RBAC). For production, a secret manager gives you better rotation and audit logs.
Step 6: Create a ConfigMap for PostgreSQL settings
Credentials belong in a Secret. Regular PostgreSQL settings belong in a ConfigMap. You can review and version them without exposing passwords.
Create postgres-config.yaml:
# $HOME/postgres-kubernetes/postgres-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-config
namespace: postgres
data:
postgresql.conf: |
listen_addresses = '*'
port = 5432
max_connections = 100
shared_buffers = '256MB'
effective_cache_size = '768MB'
maintenance_work_mem = '64MB'
checkpoint_completion_target = 0.9
wal_buffers = '16MB'
default_statistics_target = 100
random_page_cost = 1.1
effective_io_concurrency = 200
min_wal_size = '1GB'
max_wal_size = '4GB'
log_destination = 'stderr'
logging_collector = off
log_min_duration_statement = 1000
These values are a useful starting point for a pod with a 1 GB memory limit. shared_buffers gets 256 MB, or one quarter of that limit. These values aren’t universal tuning advice. Check query time, cache use, and memory use before raising them.
Apply the ConfigMap:
kubectl apply -f "$HOME/postgres-kubernetes/postgres-config.yaml"
Expected output:
configmap/postgres-config created
Confirm the object exists:
kubectl get configmap postgres-config
Expected output:
NAME DATA AGE
postgres-config 1 5s
Step 7: Create the PostgreSQL StatefulSet
The PVC belongs inside volumeClaimTemplates. Kubernetes creates postgres-data-postgres-0 for pod zero. It keeps that link when the pod is replaced.
Create postgres-statefulset.yaml. Replace longhorn with the storage class you checked earlier.
# $HOME/postgres-kubernetes/postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: postgres
spec:
serviceName: postgres
replicas: 1
podManagementPolicy: OrderedReady
updateStrategy:
type: RollingUpdate
selector:
matchLabels:
app.kubernetes.io/name: postgres
template:
metadata:
labels:
app.kubernetes.io/name: postgres
spec:
terminationGracePeriodSeconds: 60
securityContext:
fsGroup: 999
fsGroupChangePolicy: OnRootMismatch
containers:
- name: postgres
image: postgres:17.5-bookworm
imagePullPolicy: IfNotPresent
args:
- "-c"
- "config_file=/etc/postgresql/postgresql.conf"
ports:
- name: postgres
containerPort: 5432
protocol: TCP
envFrom:
- secretRef:
name: postgres-credentials
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
readinessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
- name: postgres-config
mountPath: /etc/postgresql/postgresql.conf
subPath: postgresql.conf
readOnly: true
volumes:
- name: postgres-config
configMap:
name: postgres-config
volumeClaimTemplates:
- metadata:
name: postgres-data
labels:
app.kubernetes.io/name: postgres
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 10Gi
Check these fields twice:
serviceName: postgreslinks the StatefulSet to the headless Service.replicas: 1runs one source database.PGDATAuses a subdirectory inside the mounted volume. Some storage systems place files at the volume root, which can block PostgreSQL setup.fsGroup: 999lets PostgreSQL write to CSI-mounted volumes that support this setting.volumeClaimTemplatescreates one claim for each pod ordinal.ReadWriteOnceallows attachment to one node at a time. Some drivers still allow more than one pod on that node.- The readiness and liveness probes run
pg_isready. This checks whether PostgreSQL accepts connections.

Ask the API server to check the manifest without saving it:
kubectl apply \
--dry-run=server \
-f "$HOME/postgres-kubernetes/postgres-statefulset.yaml"
Expected output:
statefulset.apps/postgres created (server dry run)
A server-side dry run checks schema and admission policies against the real cluster. It can’t prove that the storage class can create a volume.
Apply the StatefulSet:
kubectl apply -f "$HOME/postgres-kubernetes/postgres-statefulset.yaml"
Expected output:
statefulset.apps/postgres created

Step 8: Wait for PostgreSQL to become ready
Watch the rollout:
kubectl rollout status statefulset/postgres --timeout=5m
Expected output:
Waiting for 1 pods to be ready…
statefulset rolling update complete 1 pods at revision postgres-xxxxxxxxxx…
Five minutes gives slower CSI systems time to create and attach the volume. If the command times out, check events before deleting anything.
List the pod:
kubectl get pods -l app.kubernetes.io/name=postgres -o wide
Expected output:
NAME READY STATUS RESTARTS AGE IP NODE
postgres-0 1/1 Running 0 45s 10.42.1.117 k8s-worker-1

You’ll see only postgres-0 because this setup has one replica. A higher count creates ordered pod names and separate PVCs. It doesn’t create database replicas. Each extra pod starts a separate database.
Check the PostgreSQL startup log:
kubectl logs postgres-0 --tail=30
Expected output includes:
database system is ready to accept connections
This message confirms that PostgreSQL finished starting. Kubernetes Running only means the container process hasn’t exited.
Step 9: Verify that the PVC is bound
List the claims:
kubectl get pvc
Expected output:
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
postgres-data-postgres-0 Bound pvc-12345678-90ab-cdef-1234-567890abcdef 10Gi RWO longhorn
Describe the claim:
kubectl describe pvc postgres-data-postgres-0
Look for:
Status: Bound
StorageClass: longhorn
Capacity: 10Gi
Access Modes: RWO

Bound means Kubernetes found or created a PersistentVolume. It doesn’t prove that backups work. It also says nothing about replication or node failure. Test those parts on their own.
Step 10: Verify the stable per-pod DNS name
Start a temporary DNS test pod:
kubectl run dns-test \
--image=busybox:1.36 \
--restart=Never \
--command -- sleep 300
Wait for it:
kubectl wait --for=condition=Ready pod/dns-test --timeout=60s
Expected output:
pod/dns-test condition met
Resolve the stable hostname:
kubectl exec dns-test -- nslookup postgres-0.postgres.postgres.svc.cluster.local
Expected output resembles:
Server: 10.43.0.10
Address 1: 10.43.0.10 kube-dns.kube-system.svc.cluster.localName: postgres-0.postgres.postgres.svc.cluster.local
Address 1: 10.42.1.117 postgres-0.postgres.postgres.svc.cluster.local

Compare that address with the pod IP:
kubectl get pod postgres-0 -o wide
Expected output:
NAME READY STATUS RESTARTS AGE IP NODE
postgres-0 1/1 Running 0 45s 10.42.1.117 k8s-worker-1
Delete the test pod when you’re done:
kubectl delete pod dns-test
Expected output:
pod “dns-test” deleted
Applications in the postgres namespace can use this shorter hostname:
postgres-0.postgres
The headless Service exposes the pod’s identity. With one database, applications can also use postgres.postgres.svc.cluster.local. A multi-node design needs a Service that knows which replica is primary. DNS can’t decide which PostgreSQL node accepts writes.
Step 11: Verify PostgreSQL from inside the pod
Check the server version:
kubectl exec postgres-0 -- psql --version
Expected output:
psql (PostgreSQL) 17.5
Run PostgreSQL’s readiness check:
kubectl exec postgres-0 -- sh -c 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
Expected output:
/var/run/postgresql:5432 – accepting connections
Open an interactive PostgreSQL session:
kubectl exec -it postgres-0 -- sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
The prompt should resemble:
psql (17.5)
Type “help” for help.appdb=#
At the prompt, check the connection:
\conninfo
\dt
\q
\conninfo prints the active database and user. \dt lists tables. \q exits without another shell command to remember.

Step 12: Prove that database data survives pod replacement
Use PostgreSQL’s pgbench tool to create a small test database and its standard benchmark schema:
kubectl exec postgres-0 -- sh -c 'createdb -U "$POSTGRES_USER" persistence_test'
Initialize it:
kubectl exec postgres-0 -- sh -c 'pgbench -i -s 1 -U "$POSTGRES_USER" persistence_test'
Expected output ends with:
vacuuming…
creating primary keys…
done in approximately 1 second
Create a logical dump and calculate its checksum before replacing the pod:
kubectl exec postgres-0 -- sh -c 'pg_dump -Fc -U "$POSTGRES_USER" persistence_test -f /tmp/persistence-test.dump'
kubectl exec postgres-0 -- sha256sum /tmp/persistence-test.dump
Record the checksum. Example:
6d7fbd8a72f92778b8d9f90557e53390c106f89e1ed0cc911abe03271025b2ba /tmp/persistence-test.dump
Delete the pod:
kubectl delete pod postgres-0
Note: Deleting this pod causes a database outage. Run this test only during an approved maintenance window. The command leaves the PVC intact.
Expected output:
pod “postgres-0” deleted
The StatefulSet creates another postgres-0 and attaches postgres-data-postgres-0 again:
kubectl wait --for=condition=Ready pod/postgres-0 --timeout=5m
Expected output:
pod/postgres-0 condition met
Confirm that the same claim is mounted:
kubectl describe pod postgres-0
Under Volumes, you should see:
ClaimName: postgres-data-postgres-0
ReadOnly: false
Create a second dump after the restart:
kubectl exec postgres-0 -- sh -c 'pg_dump -Fc -U "$POSTGRES_USER" persistence_test -f /tmp/persistence-test.dump'
Check that PostgreSQL can read the dump archive:
kubectl exec postgres-0 -- pg_restore --list /tmp/persistence-test.dump
Expected output includes entries for the standard pgbench objects. The database survived pod replacement, and PostgreSQL can still read it.

Logical dump files can have different hashes because their archive metadata changes. A readable dump after restart is a better test than matching two dump hashes.
Configuration
Common StatefulSet settings
| Setting | Recommended starting value | Why it matters |
|---|---|---|
replicas | 1 | Raw StatefulSet replicas are independent databases, not automatic PostgreSQL replicas. |
serviceName | postgres | Must match the headless Service to create stable DNS identities. |
storageClassName | Cluster-specific | Selects the CSI provisioner that creates each persistent volume. |
| Requested storage | 10Gi or more | Defines the initial capacity of each pod’s PVC. |
| Access mode | ReadWriteOnce | Fits common block storage and single PostgreSQL writers. |
terminationGracePeriodSeconds | 60 | Gives PostgreSQL time to shut down cleanly. |
PGDATA | /var/lib/postgresql/data/pgdata | Keeps database files in a clean subdirectory on the PVC. |
| Memory request | 512Mi | Gives the scheduler a realistic baseline. |
| Memory limit | 1Gi | Prevents unbounded use, but an undersized limit can cause out-of-memory restarts. |
shared_buffers | About 25% of memory | A practical starting point, not a universal performance rule. |
These values suit the 1 GB pod used here. A real workload may need more memory or fewer than 100 connections. Copying a ConfigMap only gets PostgreSQL tuning so far. Measure first.
Changing PostgreSQL settings
Edit the local ConfigMap manifest:
nano "$HOME/postgres-kubernetes/postgres-config.yaml"
Apply it:
kubectl apply -f "$HOME/postgres-kubernetes/postgres-config.yaml"
Mounted ConfigMap files update after a short delay. PostgreSQL won’t reload every setting on its own. Restart the pod through the StatefulSet:
kubectl rollout restart statefulset/postgres
Then wait:
kubectl rollout status statefulset/postgres --timeout=5m
Warning: Restarting the only PostgreSQL pod causes a short outage. Schedule the change accordingly.
For settings that support reloads, pg_reload_conf() can avoid a restart. Check each parameter’s context in the PostgreSQL docs before using that method.
Expanding persistent storage
First check whether the storage class supports expansion:
kubectl get storageclass longhorn -o jsonpath='{.allowVolumeExpansion}'
Expected output:
true
Edit the existing claim:
kubectl edit pvc postgres-data-postgres-0
Change the size under spec.resources.requests.storage, then save and exit. For example, change 10Gi to 20Gi.
Watch the claim:
kubectl get pvc postgres-data-postgres-0 --watch
The CSI driver controls how expansion works. Some drivers grow the filesystem online. Others need the pod to be recreated. Editing volumeClaimTemplates alone may not resize an existing claim.
Connecting applications
Use port 5432 and one of these hostnames:
postgres.postgres.svc.cluster.local
postgres-0.postgres.postgres.svc.cluster.local
Both names reach the same database in this one-pod setup. A production operator will often create separate Services for read-write, read-only, and replica traffic.
Avoid a public LoadBalancer for PostgreSQL unless you have a clear requirement. Prefer cluster access, a private network, or a temporary port forward:
kubectl port-forward pod/postgres-0 5432:5432
5432:5432 maps local TCP port 5432 to PostgreSQL in the pod. Stop it with Ctrl+C. Access depends on the local listener and your Kubernetes permissions. Don’t use it as a permanent endpoint.
Backup Basics
Create a logical backup with pg_dump
pg_dump is the simplest portable backup for a small self-hosted database. It takes a consistent logical backup without stopping PostgreSQL. Large databases can still take hours and cause heavy disk I/O.
Create the local backup directory:
mkdir -p "$HOME/postgres-backups"
Write a custom-format dump inside the pod:
kubectl exec postgres-0 -- sh -c 'pg_dump -Fc -U "$POSTGRES_USER" "$POSTGRES_DB" -f /tmp/appdb.dump'
Check the local file:
ls -lh "$HOME/postgres-backups/appdb-2026-07-27.dump"
Check the archive with local PostgreSQL client tools, if installed:
pg_restore --list "$HOME/postgres-backups/appdb-2026-07-27.dump"
A copied file is only half a backup. Store it outside the cluster. Then test a restore into another database or recovery system. Restore tests find missing roles, extensions, and permissions while the source database still works.
Take a CSI volume snapshot
Volume snapshots are quick and useful when you need the full volume. They aren’t as portable as pg_dump, and the results depend on the CSI driver.
Check for a snapshot class:
kubectl get volumesnapshotclass
Example output:
NAME DRIVER DELETIONPOLICY
longhorn-snapshot driver.longhorn.io Delete
Create postgres-snapshot.yaml:
# $HOME/postgres-kubernetes/postgres-snapshot.yaml
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: postgres-data-2026-07-27
namespace: postgres
spec:
volumeSnapshotClassName: longhorn-snapshot
source:
persistentVolumeClaimName: postgres-data-postgres-0
Replace longhorn-snapshot with your class, then apply it:
kubectl apply -f "$HOME/postgres-kubernetes/postgres-snapshot.yaml"
Expected output:
volumesnapshot.snapshot.storage.k8s.io/postgres-data-2026-07-27 created
Check readiness:
kubectl get volumesnapshot postgres-data-2026-07-27
Expected output:
NAME READYTOUSE SOURCEPVC AGE
postgres-data-2026-07-27 true postgres-data-postgres-0 30s
Storage snapshots may be crash-consistent instead of application-consistent. PostgreSQL can usually recover such an image with its write-ahead log. Still, pg_dump is the safer portable choice for one database. Production recovery needs physical backups, WAL archiving, restore tests, and off-cluster copies.
When to Graduate to CloudNativePG
Stop managing the raw StatefulSet when the database needs any of these:
- Automated primary failover.
- Replication with health-aware reconciliation.
- Point-in-time recovery.
- Continuous backup to object storage.
- Safe rolling PostgreSQL upgrades.
- Managed certificates and connection Services.
- Backup retention policies and restore workflows.
- Clear recovery-point and recovery-time objectives.
- Reliable operation across node and zone failures.
CloudNativePG is a practical default for Kubernetes PostgreSQL deployments in 2026. It uses custom resources and controllers to manage node roles, replication, failover, Services, backups, and upgrades.

CloudNativePG usually needs fewer parts than a Patroni stack. It doesn’t require Patroni or a separate distributed configuration store. You get fewer control-plane parts to monitor and debug. Patroni remains proven and may suit teams that already run it. A new Kubernetes setup needs integration work that CloudNativePG already handles.
An operator still needs sound storage, capacity planning, monitoring, restore tests, and written failure steps. It also adds custom resources and a controller that you must upgrade. For disposable development databases, the raw StatefulSet is easier to inspect and has fewer parts.
Tips and Troubleshooting
Pod is stuck in CrashLoopBackOff
Typical symptoms:
NAME READY STATUS RESTARTS AGE
postgres-0 0/1 CrashLoopBackOff 6 8m
Inspect the current and previous container logs:
kubectl logs postgres-0 --tail=100
kubectl logs postgres-0 --previous --tail=100
Describe the pod:
kubectl describe pod postgres-0
The previous log is often more useful because Kubernetes may have restarted the failed container already. Common causes include:
- The Secret is missing
POSTGRES_PASSWORD. - User ID
999can’t write to the mounted PVC. PGDATAhas files from another PostgreSQL major version.- The container passed its memory limit.
- The ConfigMap has an invalid PostgreSQL setting.
Confirm the Secret keys:
kubectl describe secret postgres-credentials
Check volume permissions:
kubectl exec postgres-0 -- id
If the container stays up long enough, inspect the mount:
kubectl exec postgres-0 -- ls -ld /var/lib/postgresql/data
Don’t delete the PVC to clear a production startup failure. Its reclaim policy may delete the backing volume for good. Fix the permissions or version issue, or restore from a tested backup.
PVC remains Pending
Inspect the PVC events:
kubectl describe pvc postgres-data-postgres-0
Typical messages include:
storageclass.storage.k8s.io “longhorn” not found
or:
waiting for first consumer to be created before binding
Check the configured class:
kubectl get storageclass
Check the pod scheduling events:
kubectl describe pod postgres-0
Work through these checks:
- Correct
storageClassNamein the StatefulSet before relying on the workload. - Confirm the CSI controller and node parts are running.
- Check storage capacity.
- Confirm that a suitable node is available.
- With
WaitForFirstConsumer, fix pod scheduling errors first.
A StatefulSet PVC remains after you remove the StatefulSet by default. If you recreate the workload with the same name, check old claims first. Old storage has a habit of being both useful and dangerous.
Per-pod DNS does not resolve
Confirm that the Service is headless:
kubectl get service postgres -o jsonpath='{.spec.clusterIP}'
Expected output:
None
Confirm that serviceName matches:
kubectl get statefulset postgres -o jsonpath='{.spec.serviceName}'
Expected output:
postgres
Check the Service endpoints:
kubectl get endpointslice -l kubernetes.io/service-name=postgres
If there are no endpoints, compare the Service selector with the pod labels:
kubectl get pod postgres-0 --show-labels
DNS records can take a few seconds to appear. If other Kubernetes Services also fail, check CoreDNS. If only this Service fails, a selector mismatch is more likely.
Data appears missing after rescheduling
Check the pod ordinal:
kubectl get pod -l app.kubernetes.io/name=postgres
Check the mounted claim:
kubectl describe pod postgres-0
Check the claim status:
kubectl get pvc postgres-data-postgres-0
The pod should remain postgres-0 and mount postgres-data-postgres-0.
Data often appears missing because of one of these mistakes:
- The manifest used
emptyDirinstead ofvolumeClaimTemplates. - The new pod mounted a different claim.
- PostgreSQL used another
PGDATApath and created a second data directory.
Check the active PGDATA value:
kubectl exec postgres-0 -- sh -c 'printf "%s\n" "$PGDATA"'
Expected output:
/var/lib/postgresql/data/pgdata
Check the path and mounted claim before restoring data. A restore into the wrong directory can turn a setup error into real data loss.
A second pod is stale or contains different data
A raw StatefulSet doesn’t create PostgreSQL replicas. If you set replicas to 2, postgres-1 got an empty PVC and started a separate database.
Return the StatefulSet to one replica:
kubectl scale statefulset/postgres --replicas=1
Expected output:
statefulset.apps/postgres scaled
Scaling down will usually leave postgres-data-postgres-1 behind. Check the claim and its contents before deleting it.
For replication and failover, move to CloudNativePG. Sending traffic to separate StatefulSet pods through one load-balanced Service creates conflicting databases. That’s an inventive but expensive form of sharding.
Application connections are refused
Check pod readiness:
kubectl get pod postgres-0
Check PostgreSQL itself:
kubectl exec postgres-0 -- sh -c 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
Check Service endpoints:
kubectl get endpointslice -l kubernetes.io/service-name=postgres
Make sure the application uses port 5432 and the correct namespace-qualified hostname. NetworkPolicy rules can block traffic even when DNS and PostgreSQL work.
Also check whether the client requires TLS. This setup doesn’t configure PostgreSQL certificates. Clients that require encrypted connections will reject it until you add and manage TLS.
Image upgrade causes a major-version error
Changing from a PostgreSQL 17 image to PostgreSQL 18 won’t upgrade the data directory. Major releases need a supported logical migration or pg_upgrade.
Patch updates within PostgreSQL 17 are usually routine. Take a backup and test the image first. Pin a tag such as postgres:17.5-bookworm. The latest tag can cross a major version and turn a normal rollout into recovery work.
StatefulSet rollout is stuck
Check its rollout state:
kubectl rollout status statefulset/postgres --timeout=2m
Inspect the update revision:
kubectl get statefulset postgres
Then check pod events and logs:
kubectl describe pod postgres-0
kubectl logs postgres-0 --tail=100
If the new configuration can’t start, fix the manifest and apply it again. Avoid forced deletion while the volume is attached elsewhere. Some CSI drivers need several minutes to detach a block device safely. Impatience doesn’t make storage fencing faster.
Wrapping Up
| Step | Action | Applies To |
|---|---|---|
| 1 | Verify kubectl and cluster access | macOS and web shell |
| 2 | Confirm a working block storage class | Kubernetes cluster |
| 3–7 | Create the namespace, Service, Secret, ConfigMap, and StatefulSet | Kubernetes cluster |
| 8–12 | Verify readiness, PVC binding, DNS, PostgreSQL, and persistence | Kubernetes cluster |
| Backup | Create logical dumps and supported CSI snapshots | Database operations |
| Production | Move to CloudNativePG for replication and failover | Production workloads |
PostgreSQL 17 now runs as postgres-0 with a stable name and a 10 GiB persistent claim. You’ve tested pod replacement and confirmed that PostgreSQL reconnects to the same storage.
I’d keep this raw StatefulSet for learning, development, or a small workload that can handle downtime. When availability affects anyone beyond the cluster maintainer, move to CloudNativePG and spend the saved time testing restores.
Response Format
Return your response in this exact format: