How to Deploy PostgreSQL on Kubernetes with Persistent Storage (2026 Guide)

how to run postgresql database in kubernetes cluster

Running a stateful database like PostgreSQL on Kubernetes used to make experienced sysadmins nervous — and for good reason. Kubernetes was built with stateless workloads in mind, and a database that loses its data on every pod restart is worse than useless. But with StatefulSets, PersistentVolumeClaims, and a mature ecosystem of storage drivers and operators, PostgreSQL on Kubernetes is now a well-trodden, production-grade pattern used by companies of every size.

This guide walks through the entire process: from understanding how Kubernetes storage primitives work, to deploying a single-instance PostgreSQL StatefulSet with persistent storage, to scaling toward a highly available cluster with Patroni or CloudNativePG. By the end, you will have a working, durable PostgreSQL deployment and a clear mental model of why each manifest is structured the way it is.

Table of Contents

  1. Why Run PostgreSQL on Kubernetes
  2. Core Concepts: PV, PVC, and StorageClass
  3. Architecture Overview
  4. Prerequisites
  5. Step 1 — Create a Namespace
  6. Step 2 — Define a StorageClass
  7. Step 3 — Create a Secret for Credentials
  8. Step 4 — Create a ConfigMap for PostgreSQL Configuration
  9. Step 5 — Deploy PostgreSQL as a StatefulSet
  10. Step 6 — Expose PostgreSQL with a Service
  11. Step 7 — Verify the Deployment
  12. Comparing Persistent Storage Options
  13. Deploying with Helm (Alternative Method)
  14. Backup and Restore Strategy
  15. High Availability with Patroni or CloudNativePG
  16. Monitoring PostgreSQL on Kubernetes
  17. Security Best Practices
  18. Performance Tuning Tips
  19. Troubleshooting Guide
  20. Conclusion
  21. FAQ

Why Run PostgreSQL on Kubernetes

Teams that already run their application layer on Kubernetes often want their database on the same platform for a few practical reasons:

  • Unified operations. One control plane, one set of RBAC policies, one monitoring stack for both app and database.
  • Declarative infrastructure. The entire PostgreSQL topology — storage, config, secrets, networking — lives in version-controlled YAML instead of manual server provisioning.
  • Portability. The same manifests can run on-premises, on a bare-metal cluster, or on any major cloud provider’s managed Kubernetes offering with only the StorageClass changed.
  • Self-healing. Kubernetes automatically reschedules a failed PostgreSQL pod, and — as long as storage is truly persistent — the database resumes with no data loss.

The trade-off is complexity: you now own the operational burden of persistent storage, backup automation, and failover logic that a managed database service would otherwise handle. This guide assumes that trade-off is intentional and shows how to do it correctly.

Core Concepts: PV, PVC, and StorageClass

Before touching any YAML, it helps to understand the three building blocks Kubernetes uses to give a pod durable disk space.

  • PersistentVolume (PV): A piece of storage in the cluster, provisioned either manually by an administrator or dynamically by a StorageClass. It exists independently of any pod’s lifecycle.
  • PersistentVolumeClaim (PVC): A request for storage made by a pod. The PVC specifies size and access mode; Kubernetes binds it to a matching PV.
  • StorageClass: A template that defines how PVs get created — which storage backend (CSI driver) to use, the reclaim policy, and volume parameters like disk type or IOPS.

The relationship looks like this:

 ┌────────────┐      requests      ┌─────────────┐      binds to       ┌──────────────┐
 │  Pod       │ ─────────────────▶│    PVC      │ ──────────────────▶ │     PV       │
 │(PostgreSQL)│                    │(1 per pod)  │                     │(actual disk) │
 └────────────┘                    └─────────────┘                     └──────────────┘
                                          ▲
                                          │ dynamically provisioned by
                                          │
                                   ┌─────────────┐
                                   │StorageClass │
                                   │(e.g. gp3,   │
                                   │ ssd, nfs)   │
                                   └─────────────┘

For PostgreSQL specifically, the PVC must use ReadWriteOnce (RWO) access mode, since only one pod should ever write to the data directory at a time. Using ReadWriteMany for a single PostgreSQL instance’s data directory is a common and dangerous misconfiguration — PostgreSQL is not designed for concurrent writers on the same data files.

Architecture Overview

Here is the full architecture this guide builds, in ASCII form:

                         ┌─────────────────────────────────────────┐
                         │         Kubernetes Namespace            │
                         │               postgres                  │
                         │                                         │
                         │┌───────────────┐     ┌────────────────┐ │
   Application Pods ───▶│ │  Service     │───▶│  StatefulSet    │ │
   (via ClusterIP)       ││  postgres-svc │    │  postgres-sts   │ │
                         │└───────────────┘    │  (1..N replicas)│ │
                         │                     └────────┬────────┘ │
                         │                              │          │
                         │                     ┌────────▼────────┐ │
                         │                    │  Pod: postgres-0 │ │
                         │                    │  ┌─────────────┐ │ │
                         │                    │  │ PostgreSQL  │ │ │
                         │                    │  │ container   │ │ │
                         │                    │  └──────┬──────┘ │ │
                         │                    └─────────┼────────┘ │
                         │                              │          │
                         │                    ┌─────────▼────────┐ │
                         │                    │       PVC        │ │
                         │                    │ postgres-data-0  │ │
                         │                    └─────────┬────────┘ │
                         │                              │          │
                         └──────────────────────────────┼──────────┘
                                                                │
                                                       ┌────────▼────────┐
                                                       │  V / CSI Disk   │
                                                       │(EBS/PD/Longhorn)│
                                                       └─────────────────┘

Key design decisions embedded in this architecture:

  • A StatefulSet instead of a Deployment, so each replica gets a stable hostname (postgres-0, postgres-1, …) and its own dedicated PVC via volumeClaimTemplates.
  • A headless Service for stable DNS-based pod discovery, which matters once you introduce replication.
  • Secrets and ConfigMaps kept separate from the StatefulSet manifest so credentials never live in plain YAML committed to Git.

Prerequisites

Before starting, make sure you have:

  • A working Kubernetes cluster (v1.28+) — this can be a managed cluster (EKS, GKE, AKS) or a self-managed cluster (kubeadm, k3s, RKE2).
  • kubectl configured and authenticated against the cluster.
  • A CSI (Container Storage Interface) driver installed and functioning, so dynamic provisioning works. Most managed Kubernetes services ship with one by default (gp2/gp3 on EKS, pd-standard/pd-ssd on GKE, managed-csi on AKS).
  • At least 2 vCPU and 4 GB RAM of spare capacity on a worker node for a single PostgreSQL instance.
  • helm (v3+) installed if you plan to follow the Helm-based alternative in this guide.

Step 1 — Create a Namespace

Isolate the database workload in its own namespace for cleaner RBAC and resource quota management.

kubectl create namespace postgres

Step 2 — Define a StorageClass

If your cluster already has a default StorageClass, you can skip this step and reference it directly. Otherwise, define one explicitly so you control the reclaim policy — Retain is strongly recommended for databases so a deleted PVC does not silently destroy your data.

# storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: postgres-ssd
provisioner: ebs.csi.aws.com   # change to your CSI driver, e.g. pd.csi.storage.gke.io
parameters:
  type: gp3
  fsType: ext4
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
kubectl apply -f storageclass.yaml

volumeBindingMode: WaitForFirstConsumer delays volume binding until a pod actually needs it, which avoids scheduling a PostgreSQL pod on a node in a different availability zone than its disk — a subtle but common cause of pods stuck in Pending.

Step 3 — Create a Secret for Credentials

Never hardcode database credentials in a StatefulSet manifest. Store them as a Kubernetes Secret.

kubectl create secret generic postgres-secret \
  --namespace postgres \
  --from-literal=POSTGRES_USER=pgadmin \
  --from-literal=POSTGRES_PASSWORD='ChangeThisStrongPassword!' \
  --from-literal=POSTGRES_DB=appdb

For GitOps workflows, consider Sealed Secrets or External Secrets Operator instead of raw kubectl create secret, so the secret manifest itself can be safely committed to version control.

Step 4 — Create a ConfigMap for PostgreSQL Configuration

Custom tuning parameters (covered in more detail later) can be injected via a ConfigMap mounted as postgresql.conf.

# postgres-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: postgres-config
  namespace: postgres
data:
  POSTGRES_INITDB_ARGS: "--data-checksums"
  postgresql.conf: |
    max_connections = 200
    shared_buffers = 512MB
    effective_cache_size = 1536MB
    maintenance_work_mem = 128MB
    wal_buffers = 16MB
    checkpoint_completion_target = 0.9
    random_page_cost = 1.1
    log_min_duration_statement = 250
kubectl apply -f postgres-configmap.yaml

Step 5 — Deploy PostgreSQL as a StatefulSet

This is the core manifest. It combines the Secret, ConfigMap, and a volumeClaimTemplate that provisions a dedicated PVC per replica.

# postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres-sts
  namespace: postgres
spec:
  serviceName: postgres-svc
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16.4
          ports:
            - containerPort: 5432
              name: postgres
          envFrom:
            - secretRef:
                name: postgres-secret
          env:
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          volumeMounts:
            - name: postgres-data
              mountPath: /var/lib/postgresql/data
            - name: postgres-config
              mountPath: /etc/postgresql/postgresql.conf
              subPath: postgresql.conf
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              cpu: "2"
              memory: "2Gi"
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "pgadmin"]
            initialDelaySeconds: 10
            periodSeconds: 10
          livenessProbe:
            exec:
              command: ["pg_isready", "-U", "pgadmin"]
            initialDelaySeconds: 30
            periodSeconds: 20
      volumes:
        - name: postgres-config
          configMap:
            name: postgres-config
  volumeClaimTemplates:
    - metadata:
        name: postgres-data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: postgres-ssd
        resources:
          requests:
            storage: 20Gi
kubectl apply -f postgres-statefulset.yaml

A few details worth flagging:

  • PGDATA is set to a subdirectory of the mount path (/var/lib/postgresql/data/pgdata), not the mount root. Postgres’s initdb refuses to initialize into a non-empty directory, and most CSI drivers create a lost+found folder at the mount root that would otherwise trigger this failure.
  • readinessProbe and livenessProbe both use pg_isready rather than a TCP check, since a TCP-open port does not guarantee PostgreSQL has finished recovery or is actually accepting queries.
  • Resource requests and limits are set explicitly. Without them, a noisy neighbor pod can starve PostgreSQL of CPU during a checkpoint storm.

Step 6 — Expose PostgreSQL with a Service

A headless Service gives each StatefulSet pod a predictable DNS name (postgres-sts-0.postgres-svc.postgres.svc.cluster.local), which matters for replication topologies later.

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

If application pods only need to reach the primary and don’t care about DNS stability, a regular ClusterIP Service works too — just drop clusterIP: None.

kubectl apply -f postgres-service.yaml

Step 7 — Verify the Deployment

kubectl get pods -n postgres
kubectl get pvc -n postgres
kubectl get pv

Expected output shape:

NAME             READY   STATUS    RESTARTS   AGE
postgres-sts-0   1/1     Running   0          2m

NAME                             STATUS   VOLUME       CAPACITY   ACCESS MODES
postgres-data-postgres-sts-0     Bound    pvc-6f21a3   20Gi       RWO

Connect and run a quick sanity check:

kubectl exec -it postgres-sts-0 -n postgres -- psql -U pgadmin -d appdb -c "SELECT version();"

To confirm persistence actually works, delete the pod and watch Kubernetes recreate it against the same PVC:

kubectl delete pod postgres-sts-0 -n postgres
kubectl get pods -n postgres -w

The new pod should come up Running with the same data intact — this is the entire point of the exercise.

Comparing Persistent Storage Options

Not all storage backends behave the same for a database workload. Here’s how the common options compare:

Storage TypeLatencyMulti-AZ FailoverSnapshot SupportBest For
Local NVMe (local-path)LowestNo — node-boundManual onlySingle-node dev/test clusters
Cloud block storage (EBS gp3, PD-SSD, Azure Disk)LowDepends on zoneNative (cloud snapshots)Managed cloud Kubernetes, production single-writer DBs
NFSMedium–HighYesDepends on NASShared read access, non-latency-sensitive workloads
LonghornMediumYes (replicated)Built-inOn-prem/bare-metal clusters wanting cloud-like resilience
Rook-CephMediumYes (replicated)Built-inLarge on-prem clusters, multi-tenant storage needs

For a single-primary PostgreSQL instance, cloud block storage or Longhorn are the most common production choices. NFS is generally discouraged for the primary data directory because of file-locking semantics and higher write latency, though it can be acceptable for WAL archiving destinations.

Deploying with Helm (Alternative Method)

For teams that prefer not to hand-write every manifest, the Bitnami PostgreSQL Helm chart wraps the same StatefulSet/PVC pattern with configurable values.

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

helm install my-postgres bitnami/postgresql \
  --namespace postgres \
  --set auth.username=pgadmin \
  --set auth.password='ChangeThisStrongPassword!' \
  --set auth.database=appdb \
  --set primary.persistence.storageClass=postgres-ssd \
  --set primary.persistence.size=20Gi \
  --set primary.resources.requests.cpu=500m \
  --set primary.resources.requests.memory=1Gi

The Helm route is faster to bootstrap and includes sensible defaults for probes and security contexts out of the box, at the cost of less transparency into exactly what gets created. For learning the mechanics, hand-written manifests (as in Steps 1–7) are more instructive; for repeatable production rollouts, Helm or an operator is usually the better long-term choice.

Backup and Restore Strategy

Persistent storage protects against pod restarts and node failures — it does not protect against accidental DROP TABLE, ransomware, or a corrupted WAL. A backup strategy is mandatory, not optional.

A common pattern is a Kubernetes CronJob running pg_dump on a schedule, writing to an external object store:

# postgres-backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: postgres-backup
  namespace: postgres
spec:
  schedule: "0 2 * * *"   # every day at 02:00
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: pg-backup
              image: postgres:16.4
              command:
                - /bin/sh
                - -c
                - >
                  pg_dump -h postgres-svc -U pgadmin -d appdb
                  | gzip > /backup/appdb-$(date +%F).sql.gz
              envFrom:
                - secretRef:
                    name: postgres-secret
              volumeMounts:
                - name: backup-storage
                  mountPath: /backup
          restartPolicy: OnFailure
          volumes:
            - name: backup-storage
              persistentVolumeClaim:
                claimName: postgres-backup-pvc

For larger databases, pg_dump logical backups become slow; consider pgBackRest or WAL-G for physical backups with point-in-time recovery (PITR) instead. Both integrate cleanly with S3-compatible object storage and are the standard choice used by production-grade PostgreSQL operators.

High Availability with Patroni or CloudNativePG

A single-replica StatefulSet, as built above, still has a single point of failure: if the node hosting postgres-sts-0 goes down, there is downtime while Kubernetes reschedules the pod and PostgreSQL replays its WAL.

For true high availability, two mature options dominate the ecosystem:

FeaturePatroniCloudNativePG
Deployment styleSidecar + DCS (etcd/Consul)Native Kubernetes Operator (CRD-based)
Failover mechanismConsensus store leader electionKubernetes-native controller reconciliation
Learning curveSteeper — more moving partsGentler — declarative Cluster CRD
Backup integrationExternal (pgBackRest, WAL-G)Built-in Barman Cloud integration
MaturityLong-established (pre-Kubernetes origins)Newer, Kubernetes-native from day one

A minimal CloudNativePG cluster, once the operator is installed, looks like this:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres-cluster
  namespace: postgres
spec:
  instances: 3
  storage:
    storageClass: postgres-ssd
    size: 20Gi
  bootstrap:
    initdb:
      database: appdb
      owner: pgadmin

This single CRD replaces the StatefulSet, Service, and much of the manual failover logic from earlier steps — the operator handles leader election, replication, and automated failover internally.

Monitoring PostgreSQL on Kubernetes

Pair the deployment with the postgres_exporter for Prometheus metrics and a Grafana dashboard for visualization. If you have already set up Prometheus and Grafana via Docker Compose as covered in bckinfo.com’s Prometheus and Grafana installation guide, the same stack can scrape metrics from a postgres-exporter sidecar added to the StatefulSet pod, exposing metrics like replication lag, active connections, and cache hit ratio.

Security Best Practices

  • Run the PostgreSQL container as a non-root user (securityContext.runAsNonRoot: true); the official postgres image already supports this.
  • Restrict network access with a NetworkPolicy so only application namespaces can reach port 5432.
  • Rotate credentials stored in Secrets regularly, and prefer an external secrets manager over static kubectl create secret for production clusters.
  • Enable TLS for client connections using ssl = on in postgresql.conf with certificates mounted from a Secret.
  • Set reclaimPolicy: Retain on the StorageClass so an accidental kubectl delete pvc doesn’t silently destroy the underlying disk.

Performance Tuning Tips

  • Set shared_buffers to roughly 25% of the container’s memory limit, not the node’s total memory.
  • Pin the pod to nodes with local NVMe or high-IOPS block storage using nodeSelector or nodeAffinity for latency-sensitive workloads.
  • Use effective_io_concurrency above the default on cloud block storage, since these volumes generally support higher parallel I/O than a single spinning disk.
  • Avoid running PostgreSQL and CPU-heavy application pods on the same node without resource limits — checkpoint I/O storms are a common cause of latency spikes.

Troubleshooting Guide

SymptomLikely CauseFix
Pod stuck in PendingNo node in the PVC’s bound zone, or StorageClass misconfiguredCheck kubectl describe pvc; confirm volumeBindingMode and CSI driver health
initdb: directory not empty on first bootPGDATA set to the volume mount root instead of a subdirectorySet PGDATA to a subpath, e.g. /var/lib/postgresql/data/pgdata
Pod CrashLoopBackOff after node failurePVC stuck Terminating on the failed node, blocking reattachmentForce-delete the stuck pod with --grace-period=0; confirm CSI driver supports multi-attach protection
FATAL: password authentication failedSecret values changed but pod using cached environmentRestart the pod after updating the Secret; Kubernetes does not hot-reload env vars
High replication lagInsufficient network bandwidth or wal_buffers too lowIncrease wal_buffers; check network policy is not throttling inter-pod traffic
PVC stuck in TerminatingFinalizer left behind by CSI driver after volume detach failureCheck kubectl describe pv; manually remove the finalizer only as a last resort
Data lost after pod restartemptyDir used instead of a PVC (common copy-paste mistake)Confirm volumeClaimTemplates is used, not volumes.emptyDir

Conclusion

Deploying PostgreSQL on Kubernetes with persistent storage is no longer an exotic pattern — it is a well-understood, repeatable process once you understand the relationship between StatefulSets, PVCs, and StorageClasses. Start with a single-replica StatefulSet to validate that persistence actually survives pod restarts, layer on automated backups before anything touches production traffic, and only then move toward an HA operator like CloudNativePG or Patroni once uptime requirements justify the added operational complexity.

The manifests in this guide are a solid production starting point, but treat them as a baseline: tune resource limits to your actual workload, choose a StorageClass that matches your cluster’s underlying infrastructure, and never skip the backup CronJob — persistent storage protects against pod failure, not against human error.

For related reading on bckinfo.com, see the guides on Prometheus and Grafana monitoring stack setup, PostgreSQL query performance and EXPLAIN ANALYZE optimization, and Docker network security best practices for securing the surrounding infrastructure.

FAQ

Can I run PostgreSQL on Kubernetes in production?
Yes. With a StatefulSet, a durable StorageClass, resource limits, automated backups, and — for high availability — an operator like CloudNativePG or Patroni, PostgreSQL runs reliably in production on Kubernetes.

What is the difference between a PV and a PVC in PostgreSQL deployments?
A PersistentVolume (PV) is the actual provisioned storage resource in the cluster. A PersistentVolumeClaim (PVC) is a request for storage made by a pod; Kubernetes binds the PVC to a matching PV, and the PostgreSQL pod mounts that PVC as its data directory.

Should I use a Deployment or a StatefulSet for PostgreSQL?
Always use a StatefulSet. It provides stable network identities and a dedicated, stable PVC per replica through volumeClaimTemplates — both of which PostgreSQL needs for consistent data and, eventually, replication.

Do I need an operator like Patroni or CloudNativePG for a single PostgreSQL instance?
No. A single-replica StatefulSet with persistent storage is sufficient for development, staging, or low-criticality production workloads. Operators become worthwhile once you need automated failover across multiple replicas.

What StorageClass should I use for PostgreSQL on a managed Kubernetes cluster?
Use the SSD-backed block storage class provided by your cloud vendor — gp3 on EKS, pd-ssd on GKE, or managed-csi (Premium SSD) on AKS — with reclaimPolicy: Retain to protect against accidental data loss.

(Visited 4 times, 5 visits today)

You may also like