Creating a Production-Grade High Availability PostgreSQL Database Using CloudNativePG and Kubernetes

In this step-by-step guide, we'll walk through setting up a production-ready, high-availability PostgreSQL cluster using CloudNativePG on Kubernetes. We'll cover everything from installation to verification, ensuring each step is working before moving forward.
Prerequisites
- A running Kubernetes cluster (we'll use k3s in this example)
kubectl
installed and configuredhelm
installed (version 3.7+)
Note for managed Kubernetes users: This guide works for all Kubernetes distributions including managed services like GKE, EKS, and AKS. Key differences will be noted where applicable.
Step 1: Installing the CloudNativePG Operator
First, let's install the CloudNativePG operator using Helm:
# Add the CloudNativePG Helm repository
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
# Install the operator
helm upgrade --install cloudnative-pg cnpg/cloudnative-pg \
--namespace cnpg-system \
--create-namespace
Expected output:
NAME: cloudnative-pg
LAST DEPLOYED: Fri Apr 18 16:35:50 2025
NAMESPACE: cnpg-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CloudNativePG operator should be installed in namespace "cnpg-system".
You can now create a PostgreSQL cluster with 3 nodes as follows:
cat <<EOF | kubectl apply -f -
# Example of PostgreSQL cluster
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: cluster-example
spec:
instances: 3
storage:
size: 1Gi
EOF
Verification:
# Check if the operator pod is running
kubectl get pods -n cnpg-system
Expected output:
NAME READY STATUS RESTARTS AGE
cloudnative-pg-5b8d5d8d76-dxjlq 1/1 Running 0 12s
Step 2: Creating the PostgreSQL Cluster
Create a file named postgres-cluster.yaml
with the following production-ready configuration:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-prod
spec:
# Three instances ensures high availability with one primary and two replicas
instances: 3
# Bootstrap configuration - keeping it minimal but production-ready
bootstrap:
initdb:
# Enable data checksums to detect corruption - critical for production
dataChecksums: true
# Using C locale for best performance
localeCollate: C
localeCType: C
# Storage configuration for k3s local-path
storage:
size: 1Gi
storageClass: local-path
Note for managed Kubernetes users: Replace
storageClass: local-path
with an appropriate storage class for your platform. For production environments, use network-attached storage instead of local storage. You can list available options withkubectl get storageclass
.
Apply the configuration:
kubectl apply -f postgres-cluster.yaml
Expected output:
cluster.postgresql.cnpg.io/pg-prod created
Verification:
# Wait for the cluster to become ready
kubectl wait --for=condition=Ready cluster/pg-prod --timeout=300s
Expected output:
cluster.postgresql.cnpg.io/pg-prod condition met
You can also check that the pods are running:
kubectl get pods -w
Expected output:
NAME READY STATUS RESTARTS AGE
pg-prod-1 1/1 Running 0 104s
pg-prod-2 1/1 Running 0 76s
pg-prod-3 1/1 Running 0 49s
Step 3: Verifying Cluster Health
Check the cluster status:
kubectl describe cluster pg-prod
Look for:
Status.Ready: true
Status.InstanceStatus
showing all instances healthyStatus.CurrentPrimary
indicating your primary instance
Step 4: Accessing the Database
The operator automatically creates several Kubernetes services:
pg-prod-rw
: Read-write service (points to primary)pg-prod-ro
: Read-only service (load balances across replicas)pg-prod-r
: All instances (including primary)
The superuser credentials are automatically generated and stored in a Kubernetes secret:
# Get the superuser password
kubectl get secret pg-prod-superuser -o jsonpath="{.data.password}" | base64 -d
Connect to the database:
# Connect to the primary
kubectl exec -it pg-prod-1 -- psql -U postgres
Expected output:
Defaulted container "postgres" out of: postgres, bootstrap-controller (init)
psql (17.4 (Debian 17.4-1.pgdg110+2))
Type "help" for help.
postgres=#
Verification:
Run these SQL commands to verify the setup:
SELECT version();
SELECT pg_is_in_recovery();
Expected output:
version
-----------------------------------------------------------------------------------------------------------------------------
PostgreSQL 17.4 (Debian 17.4-1.pgdg110+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 10.2.1-6) 10.2.1 20210110, 64-bit
(1 row)
pg_is_in_recovery
-------------------
f
(1 row)
Check replication status:
SELECT client_addr, state, sync_state
FROM pg_stat_replication;
Expected output:
client_addr | state | sync_state
-------------+-----------+------------
10.42.0.50 | streaming | async
10.42.0.53 | streaming | async
(2 rows)
Step 5: Creating Application User and Database
Now that we have verified our cluster is working, let's create a non-root user and database that they can access:
-- Create user with password (replace YOUR_USERNAME and YOUR_PASSWORD)
CREATE USER YOUR_USERNAME WITH PASSWORD 'YOUR_PASSWORD';
-- Create database
CREATE DATABASE signups OWNER YOUR_USERNAME;
-- Connect to the new database
\c signups
-- Create the emails table
CREATE TABLE emails (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Grant permissions
GRANT ALL PRIVILEGES ON DATABASE signups TO YOUR_USERNAME;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO YOUR_USERNAME;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO YOUR_USERNAME;
Expected output:
CREATE ROLE
CREATE DATABASE
You are now connected to database "signups" as user "postgres".
CREATE TABLE
GRANT
GRANT
ALTER DEFAULT PRIVILEGES
Verification:
Connect as the new user with the connection string (required to pass authentication):
kubectl exec -it pg-prod-1 -- psql "postgresql://YOUR_USERNAME:YOUR_PASSWORD@localhost:5432/signups"
Expected output:
Defaulted container "postgres" out of: postgres, bootstrap-controller (init)
psql (17.4 (Debian 17.4-1.pgdg110+2))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off, ALPN: postgresql)
Type "help" for help.
signups=>
Test the permissions:
INSERT INTO emails (email) VALUES ('test@example.com');
SELECT * FROM emails;
Expected output:
INSERT 0 1
uuid | email | created
--------------------------------------+------------------+-------------------------------
0ba2d91a-03b8-41a3-9941-20c04e15851e | test@example.com | 2025-04-18 22:47:35.580751+00
(1 row)
If you output similar to above, your non-root user has been correctly created and given appropriate access to schema signups
.
Production Considerations
While our tutorial provides a functional CloudNativePG deployment, transforming it into a truly production-ready system requires addressing several critical factors. This section explains not just what to configure, but why each consideration is essential for reliability, performance, and security.
1. Storage Selection
Why it matters: Storage selection fundamentally determines whether your PostgreSQL cluster can deliver on its high-availability promise.
The example uses local-path
storage, which is the default in k3s but creates a critical limitation:
-
The local-path problem: With local-path storage in k3s, data is physically tied to a specific node's filesystem. When a pod fails and needs to be rescheduled to another node, it cannot access its previous data because that data exists only on the original node. This completely defeats the purpose of a multi-instance high-availability setup.
-
Real high availability requires: Storage volumes that can detach from one node and reattach to another, allowing pods to be rescheduled anywhere in the cluster while maintaining data access.
For k3s specifically:
- Single-node k3s: For development/testing only, local-path is acceptable since there's nowhere else to reschedule anyway
- Multi-node k3s: You must replace local-path with a network storage provider like Longhorn (which can be installed directly in k3s) or connect to external storage services
For managed Kubernetes:
- EKS: Use
gp2
orgp3
(Amazon EBS volumes) - GKE: Use
premium-rwo
for SSD performance orstandard
for HDD - AKS: Use
managed-premium
for SSD ordefault
storage class
Without appropriate storage, your database is essentially a "fake" high-availability system that will experience data loss during failover.
2. Resource Management
Why it matters: Unlike stateless applications, databases are sensitive to resource starvation and unpredictable evictions, which can cause corruption, stalled recovery, or complete failure.
Defining resource boundaries is critical:
spec:
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "1Gi" # Setting equal to request provides Guaranteed QoS
cpu: "1"
Without defined resources:
- Kubernetes will schedule pods without understanding their true needs
- PostgreSQL may be allocated insufficient memory, causing poor performance or crashes
- Under node pressure, k3s (and other Kubernetes variants) may evict your database pods without warning
- Memory overcommitment leads to OOM (Out Of Memory) kills, which are especially dangerous for database workloads
For k3s environments: k3s often runs on smaller or resource-constrained nodes, making proper sizing even more critical. If you're running on Raspberry Pi or similar devices, be especially careful to set appropriate limits based on total available system resources.
Setting memory limits equal to requests gives PostgreSQL pods the highest scheduling priority (Guaranteed QoS), protecting them from eviction during resource contention.
3. Pod Placement for True High Availability
Why it matters: Simply having three PostgreSQL instances doesn't provide high availability if they can all fail from a single event.
Without proper pod distribution, your instances might all run on the same node:
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: postgresql
operator: In
values:
- pg-prod
topologyKey: kubernetes.io/hostname
Failure scenarios without anti-affinity:
- Node failure takes down all instances simultaneously
- Maintenance on a single node impacts the entire database cluster
- Network issues affecting one node cause complete service outage
For k3s configurations:
- Single-node k3s: Anti-affinity won't apply (not enough nodes), so true HA is impossible
- Multi-node k3s: Essential to configure anti-affinity to spread across your nodes
- Edge k3s clusters: Consider spreading across geographically distributed nodes if applicable
Anti-affinity transforms numerical redundancy (three pods) into genuine fault tolerance by ensuring pods run on different physical infrastructure.
4. PostgreSQL Performance Tuning
Why it matters: Default PostgreSQL parameters are designed for compatibility across many environments, not optimal performance for your specific workload and resources.
spec:
postgresql:
parameters:
shared_buffers: "256MB" # 25% of memory
max_connections: "100"
work_mem: "4MB"
maintenance_work_mem: "64MB"
max_wal_size: "1GB"
checkpoint_completion_target: "0.9"
Impact of key parameters:
- shared_buffers: Controls PostgreSQL's data cache. Too small means excessive disk I/O; default values are often too conservative for dedicated database servers
- max_connections: Directly affects memory consumption. Each connection consumes RAM; setting too high without adequate memory will cause crashes
- work_mem: Affects complex query performance. Low values cause disk-based sorting and hashing; high values can consume excessive memory with concurrent operations
- maintenance_work_mem: Impacts VACUUM, index creation, and foreign key operations. Default values can make these operations extremely slow on larger tables
For k3s environments: Resource constraints are often tighter; tune parameters more conservatively, especially if sharing nodes with other workloads. For example, a typical k3s node might warrant smaller shared_buffers and work_mem values than a dedicated cloud instance.
5. Connection Pooling
Why it matters: PostgreSQL creates a separate backend process for each client connection, which quickly becomes resource-intensive and limits scalability.
spec:
pgBouncer:
poolMode: session # or "transaction" depending on needs
replicas: 2 # For high availability
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "500m"
Connection handling problems:
- Each PostgreSQL connection consumes 5-10MB of RAM minimum
- 100 direct connections = 500MB-1GB dedicated just to connection overhead
- Kubernetes applications typically create and destroy connections frequently
- Connection storms during deployments/restarts can overwhelm the database
For k3s deployments: Connection pooling is even more important due to typically constrained resources. It's the difference between supporting 10-20 application instances versus hundreds with the same database resources.
PgBouncer manages a small set of actual PostgreSQL connections shared across many client connections, dramatically reducing resource usage and increasing capacity.
6. Backup Strategy
Why it matters: High availability protects against instance failure, but not against data corruption, accidental deletion, or entire cluster failure.
spec:
backup:
barmanObjectStore:
destinationPath: "s3://your-backup-bucket/pg-prod/"
s3Credentials:
accessKeyId:
name: aws-creds # Kubernetes secret name
key: ACCESS_KEY_ID
secretAccessKey:
name: aws-creds
key: SECRET_ACCESS_KEY
retentionPolicy: "7d"
schedule: "0 1 * * *" # Daily at 1 AM
What HA doesn't protect against:
- Logical data corruption (bad SQL operations)
- Admin errors (dropped tables, deleted data)
- Application bugs writing incorrect data
- Kubernetes cluster-wide failures
- Storage subsystem failures affecting all nodes
For k3s environments: Local k3s clusters often lack redundant storage infrastructure, making off-cluster backups even more critical. For edge deployments, ensure backup destinations are network-accessible even during partial infrastructure failures.
Regular backups provide point-in-time recovery options that replication alone cannot offer.
7. Monitoring Setup
Why it matters: Production databases require visibility into their performance and health metrics to identify problems before they cause outages.
spec:
monitoring:
enablePodMonitor: true # If using Prometheus Operator
Without proper monitoring:
- You won't detect replication lag until it causes consistency problems
- Performance degradation may go unnoticed until users complain
- Storage problems can develop gradually until they cause complete failure
- Connection limit exhaustion appears suddenly as cascading application errors
For k3s environments: If running a minimal k3s cluster, consider a lightweight monitoring stack like Prometheus + Grafana. For very resource-constrained environments, CloudNativePG exports basic metrics that can be scraped without a full monitoring stack.
Key metrics like replication lag, transaction rates, and connection counts provide early warning of developing issues.
8. Security Hardening
Why it matters: Default configurations prioritize functionality over security, leaving your database potentially vulnerable.
- Network Policies: By default, any pod in the cluster can connect to your database
- Transport Encryption: Connections are unencrypted by default, risking data exposure
- Credential Management: Basic secrets aren't encrypted at rest in etcd
Network isolation example:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgres-ingress
spec:
podSelector:
matchLabels:
postgresql: pg-prod
ingress:
- from:
- podSelector:
matchLabels:
app: your-application
ports:
- protocol: TCP
port: 5432
For k3s environments: Security requirements don't diminish with smaller deployments. In fact, k3s often runs in edge locations with less physical security, making proper network policies and encryption even more important.
9. Platform-Specific Considerations
Why it matters: Different Kubernetes platforms have unique characteristics that affect PostgreSQL deployment.
For k3s specifically:
- Resource constraints: Many k3s deployments run on smaller nodes; configure resources accordingly
- Default CNI: k3s uses flannel by default, which supports NetworkPolicy but performs differently from other CNIs
- Local Path Provisioner: As discussed, this default storage class isn't suitable for production multi-node clusters
- Embedded control plane: In single-node k3s, control plane competes with workloads for resources
For managed services:
- EKS: Consider IRSA for S3 backup access without hardcoded credentials
- GKE: Leverage Workload Identity for GCS access
- AKS: Use Pod Identity for Azure services access
10. Lifecycle Management
Why it matters: PostgreSQL and CloudNativePG require regular updates for security, bug fixes, and new features.
Update strategies:
- Minor PostgreSQL upgrades (e.g., 14.5 → 14.6): Update
spec.imageName
and let CloudNativePG perform a rolling update - Major PostgreSQL upgrades (e.g., 14.x → 15.x): Require careful planning due to potential compatibility issues
- Operator updates: Use Helm to upgrade the operator itself
For k3s environments: Use k3s's built-in Helm support for operator updates. For air-gapped environments, plan for how you'll deliver new images for PostgreSQL upgrades.
Delaying updates increases security risks and technical debt, while rushed updates can cause outages. A well-defined upgrade process is essential for long-term reliability.
Conclusion
You now have a production-ready, high-availability PostgreSQL cluster running on Kubernetes. The CloudNativePG operator handles many operational aspects automatically, including:
- Failover and recovery
- Replication management
- Basic monitoring
- Backup coordination
Remember to:
- Regularly monitor the cluster health
- Keep the operator updated
- Test failover scenarios
- Maintain proper backups
- Follow security best practices
For production deployments, review and implement the considerations outlined in the Production Considerations section to ensure stability, performance, and true high availability.
Jacob Coccari