Beehive Advisors Logo

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

Jacob Coccari
9 min read
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 configured
  • helm 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 with kubectl 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 healthy
  • Status.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 or gp3 (Amazon EBS volumes)
  • GKE: Use premium-rwo for SSD performance or standard for HDD
  • AKS: Use managed-premium for SSD or default 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

Jacob Coccari