Installing CloudNativePG on AWS EKS — Step-by-Step Guide with Screenshots


Introduction

In Post 1, we covered the architecture — the three services, node topology, WAL replication, and how CloudNativePG thinks about PostgreSQL. Now it’s time to build it.

In this post, we’ll install the CloudNativePG operator on a real AWS EKS cluster and deploy a 3-instance PostgreSQL cluster across three availability zones. Every command has been tested in a real lab. Screenshots are attached after each major step.

By the end, you’ll have a running PostgreSQL HA cluster on Kubernetes — with a primary, two replicas, and all three routing services active.

What you’ll learn:

  • How to prepare your AWS EKS cluster for CloudNativePG
  • Two installation methods: YAML manifest vs. Helm
  • How to deploy your first 3-node PostgreSQL cluster
  • How to verify the operator, pods, services, and secrets
  • How to get a superuser connection string and connect with psql
  • How to set up basic monitoring with Prometheus and Grafana

Prerequisites

Before you start, make sure you have:

  • [ ] AWS EKS cluster running (Kubernetes 1.28+) with 2 worker nodes across 2 AZs
  • [ ] kubectl configured and pointing to your EKS cluster (kubectl get nodes works)
  • [ ] helm v3.x installed on your local machine
  • [ ] AWS EBS CSI Driver installed on EKS (required for persistent storage)
  • [ ] An EDB subscription token (free — instructions in Step 1)
  • [ ] Sufficient IAM permissions to create namespaces, deployments, CRDs, and secrets

Please create the EKS cluster in AWS from your account. I created using Cloud Shell in CA-CENTRAL-1 region in free account.

curl –silent –location “https://github.com/weaveworks/eksctl/releases/latest/download/eksctl_$(uname -s)_amd64.tar.gz” | tar xz -C /tmp 

~ $ sudo mv /tmp/eksctl /usr/local/bin 

eksctl create nodegroup \
–cluster my-cluster \
–region ca-central-1 \
–name workers-free \
–node-type t3.small \
–nodes 2 \
–nodes-min 1 \
–nodes-max 3 \
–managed

Switch the region to ca-central-1  

~ $ aws eks update-kubeconfig –region ca-central-1 –name my-cluster

~ $ kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
ip-192-168-23-123.ca-central-1.compute.internal Ready 6h6m v1.30.14-eks-93b80c6 192.168.23.123 15.157.71.115 Amazon Linux 2023.12.20260611 6.1.174-217.345.amzn2023.x86_64 containerd://2.2.4+unknown
ip-192-168-82-225.ca-central-1.compute.internal Ready 6h6m v1.30.14-eks-93b80c6 192.168.82.225 15.222.24.150 Amazon Linux 2023.12.20260611 6.1.174-217.345.amzn2023.x86_64 containerd://2.2.4+unknown
~ $ kubectl get pods -n kube-system
NAME READY STATUS RESTARTS AGE
aws-node-k6gkg 2/2 Running 0 6h9m
aws-node-n8w2d 2/2 Running 0 6h9m
coredns-567c64496-cg6hn 1/1 Running 0 7h12m
coredns-567c64496-xmtnz 1/1 Running 0 7h12m
kube-proxy-gv75q 1/1 Running 0 6h9m
kube-proxy-lksxt 1/1 Running 0 6h9m

Lab Environment

ComponentVersion / Details
KubernetesAWS EKS 1.31
CloudNativePG Operatorv1.28.1
PostgreSQL16.x (community/EDB)
Worker Nodes2 × m5.xlarge (one per AZ: us-east-1a/b/c). For free tier choose t3.small
StorageAWS EBS gp3 via EBS CSI Driver
kubectlv1.31+
helmv3.16+

Architecture Overview

Here’s what we’re building in this post:

AWS EKS Cluster (us-east-1)
│
├── Namespace: postgresql-operator-system
│   └── Deployment: postgresql-operator-controller-manager
│       └── Pod: cloudnativepg-operator (1 replica)
│
└── Namespace: postgres
    ├── Cluster: cluster-example (3 instances)
    │   ├── Pod: cluster-example-1  [PRIMARY]  → us-east-1a
    │   ├── Pod: cluster-example-2  [REPLICA]  → us-east-1b
    │  
    │
    ├── Services
    │   ├── cluster-example-rw   → Primary only
    │   ├── cluster-example-ro   → Replicas only
    │ 
    │
    └── Secrets
        ├── cluster-example-superuser
        └── cluster-example-app

Step 1: Get Your EDB Subscription Token

CloudNativePG v1.28.1 (EDB Postgres AI for CloudNativePG Cluster) requires an EDB subscription token to pull images from the EDB private container registry.

Go to https://www.enterprisedb.com/accounts/register and create a free EDB account.

Note: The token is free. You don’t need a paid EDB subscription to use the community PostgreSQL images.

To access EDB repo, please request access using the button below in the screen shot .

Once logged in, navigate to EDB Repos 2.0 and generate your token.

Copy the token and set it as an environment variable in your terminal:

export EDB_SUBSCRIPTION_TOKEN=<your-token-here>

# Verify it's set
echo $EDB_SUBSCRIPTION_TOKEN

⚠️ Security: Never commit this token to Git or expose it in shell history. Store it in AWS Secrets Manager or a password manager for team use.


Step 2: Prepare the Operator Namespace and Pull Secret

The CloudNativePG operator lives in its own dedicated namespace: postgresql-operator-system. Before installing the operator, you need to create this namespace and add the EDB image pull secret so Kubernetes can pull the operator image from the EDB registry.

# Create the operator namespace
kubectl create namespace postgresql-operator-system

# Verify namespace was created
kubectl get namespace postgresql-operator-system


Expected output:
NAME                          STATUS   AGE
postgresql-operator-system    Active   5s

Now create the image pull secret in that namespace:

kubectl create secret -n postgresql-operator-system docker-registry edb-pull-secret \
  --docker-server=docker.enterprisedb.com \
  --docker-username=<<Username to login to EDB>> \
  --docker-password=$EDB_SUBSCRIPTION_TOKEN

Expected output:

secret/edb-pull-secret created

Verify the secret exists:

kubectl get secret edb-pull-secret -n postgresql-operator-system

Expected output:

NAME              TYPE                             DATA   AGE
edb-pull-secret   kubernetes.io/dockerconfigjson   1      10s


Step 3: Install the CloudNativePG Operator

You have two installation methods. We’ll use both — Method A (YAML manifest) is the simplest for getting started. Method B (Helm) is recommended for production where you need customization.


Method A: Install via YAML Manifest (Simplest)

This installs the operator directly from the official EDB manifest URL:

kubectl apply --server-side -f \
  https://get.enterprisedb.io/pg4k/pg4k-1.28.1.yaml

Expected output (many lines — it creates CRDs, ServiceAccounts, ClusterRoles, etc.):

customresourcedefinition.apiextensions.k8s.io/backups.postgresql.k8s.enterprisedb.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/clusters.postgresql.k8s.enterprisedb.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/poolers.postgresql.k8s.enterprisedb.io serverside-applied
...
deployment.apps/postgresql-operator-controller-manager serverside-applied

Method B: Install via Helm (Recommended for Production)

curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3

chmod 777 get_helm.sh

./get_helm.sh

Downloading https://get.helm.sh/helm-v3.20.1-linux-amd64.tar.gz 

Verifying checksum… Done. 

Preparing to install helm into /usr/local/bin 

helm installed into /usr/local/bin/helm 

# Add the EDB Helm chart repository
helm repo add edb https://enterprisedb.github.io/edb-postgres-for-kubernetes-charts/
helm repo update

# Install the operator
helm upgrade --install edb-pg4k edb/edb-postgres-for-kubernetes \
  --namespace postgresql-operator-system \
  --create-namespace \
  --set image.pullSecretName=edb-pull-secret \
  --wait

Expected output:

Release "edb-pg4k" does not exist. Installing it now.
...
STATUS: deployed
REVISION: 1

For this tutorial series, we’ll use Method A (YAML manifest) to keep things explicit. The Helm method is preferred in GitOps workflows where you manage the operator via ArgoCD or Flux.


Step 4: Verify the Operator is Running

After installation, the operator should be running as a single-pod Deployment in the postgresql-operator-system namespace.

# Check the operator deployment
kubectl rollout status deployment \
  -n postgresql-operator-system \
  postgresql-operator-controller-manager

Expected output:

deployment "postgresql-operator-controller-manager" successfully rolled out
# Check the operator pod
kubectl get pods -n postgresql-operator-system

Expected output:

NAME                                                    READY   STATUS    RESTARTS   AGE
postgresql-operator-controller-manager-6d9f7b4c9-xk2p7 1/1     Running   0          2m
# Get more details about the deployment
kubectl get deployments -n postgresql-operator-system

Expected output:

NAME                                     READY   UP-TO-DATE   AVAILABLE   AGE
postgresql-operator-controller-manager   1/1     1            1           2m

[SCREENSHOT: Terminal showing operator pod in Running state] 📸 Screenshot: Operator pod running in postgresql-operator-system namespace


Step 5: Check the Custom Resource Definitions (CRDs)

The operator installs several CRDs that extend Kubernetes. These are the building blocks you’ll use in every future post.

kubectl get crds | grep enterprisedb

Expected output:

backups.postgresql.k8s.enterprisedb.io         2024-XX-XX
clusters.postgresql.k8s.enterprisedb.io        2024-XX-XX
poolers.postgresql.k8s.enterprisedb.io         2024-XX-XX
scheduledbackups.postgresql.k8s.enterprisedb.io 2024-XX-XX
imagecatalogs.postgresql.k8s.enterprisedb.io   2024-XX-XX
clusterimagecatalogs.postgresql.k8s.enterprisedb.io 2024-XX-XX

The key one is clusters.postgresql.k8s.enterprisedb.io — this is what you’ll use to define every PostgreSQL cluster going forward.

This is output from cloudshell

~ $ kubectl get configmaps,secrets,serviceaccounts -n postgresql-operator-system
NAME DATA AGE
configmap/kube-root-ca.crt 1 15h
configmap/postgresql-operator-default-monitoring 1 87m

NAME TYPE DATA AGE
secret/edb-pull-secret kubernetes.io/dockerconfigjson 1 88m
secret/postgresql-operator-ca-secret Opaque 2 87m
secret/postgresql-operator-webhook-cert kubernetes.io/tls 2 87m

NAME SECRETS AGE
serviceaccount/default 0 15h
serviceaccount/postgresql-operator-manager 0 87m
~ $ helm list -A
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
~ $ kubectl get crds | grep -i postgres
backups.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:41Z
clusterimagecatalogs.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:41Z
clusters.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:41Z
databases.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:41Z
failoverquorums.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:41Z
imagecatalogs.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:41Z
poolers.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:42Z
publications.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:42Z
scheduledbackups.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:42Z
subscriptions.postgresql.k8s.enterprisedb.io 2026-06-23T17:34:42Z
~ $ kubectl rollout status deployment \

-n postgresql-operator-system \
postgresql-operator-controller-manager
deployment “postgresql-operator-controller-manager” successfully rolled out
~ $ kubectl get pods -n postgresql-operator-system
NAME READY STATUS RESTARTS AGE
postgresql-operator-controller-manager-6c8586857-lmsn5 1/1 Running 1 (90m ago) 91m
~ $ kubectl get deployments -n postgresql-operator-system
NAME READY UP-TO-DATE AVAILABLE AGE
postgresql-operator-controller-manager 1/1 1 1


Step 6: Create a Namespace for PostgreSQL

Keep your PostgreSQL clusters in a separate namespace from the operator. This makes RBAC, monitoring, and resource management much cleaner.

kubectl create namespace postgres

# Verify
kubectl get namespace postgres

Expected output:

NAME       STATUS   AGE
postgres   Active   3s

Step 7: Deploy Your First PostgreSQL Cluster

Now the fun begins. Create a YAML file that defines a 2-instance PostgreSQL cluster:

Enable OIDC for the cluster (allows Kubernetes to use AWS IAM roles):

eksctl utils associate-iam-oidc-provider –region ca-central-1 –cluster my-cluster –approve

Create the IAM Role required to manage EBS volumes:

eksctl create iamserviceaccount –region ca-central-1 –name ebs-csi-controller-sa –namespace kube-system –cluster my-cluster –attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy –approve –role-only –role-name AmazonEKS_EBS_CSI_DriverRole

Grab your AWS Account ID and save it as a variable:

ACCOUNT_ID=$(aws sts get-caller-identity –query “Account” –output text)

Install the EBS CSI Driver Add-on:

eksctl create addon –name aws-ebs-csi-driver –cluster my-cluster –region ca-central-1 –service-account-role-arn arn:aws:iam::${ACCOUNT_ID}:role/AmazonEKS_EBS_CSI_DriverRole –force

cat <<EOF > cluster-example.yaml
apiVersion: postgresql.k8s.enterprisedb.io/v1
kind: Cluster
metadata:
  name: cluster-example
  namespace: postgres
spec:
  instances: 2

  # PostgreSQL version
  imageName: ghcr.io/cloudnative-pg/postgresql:16

  # Storage for each instance's PGDATA
  storage:
    size: 10Gi
    storageClass: gp2  # AWS EKS default StorageClass — use gp3 if configured

  # Spread instances across AZs
  affinity:
    topologyKey: topology.kubernetes.io/zone

  # PostgreSQL configuration overrides
  postgresql:
    parameters:
      max_connections: "200"
      shared_buffers: "256MB"
      log_min_duration_statement: "1000"  # Log queries slower than 1 second
EOF

Apply it:

kubectl apply -f cluster-example.yaml

Expected output:

cluster.postgresql.k8s.enterprisedb.io/cluster-example created

Step 8: Watch the Cluster Come Up

This is the exciting part. Watch the pods initialize one by one:

kubectl get pods -n postgres -w

You’ll see something like this (watch in real time):

NAME                READY   STATUS     RESTARTS   AGE
cluster-example-1   0/1     Init:0/1   0          5s
cluster-example-1   0/1     PodInitializing 0     15s
cluster-example-1   1/1     Running    0          30s    ← Primary is up
cluster-example-2   0/1     Init:0/1   0          35s
cluster-example-2   1/1     Running    0          60s    ← Replica 1 joined
cluster-example-3   0/1     Init:0/1   0          65s
cluster-example-3   1/1     Running    0          90s    ← Replica 2 joined

What’s happening: The operator creates instance 1 first as the primary (via initdb). Instances 2 and 3 join by streaming a base backup from the primary, then start WAL streaming replication. This is all automatic.

Press Ctrl+C to stop watching once all 2 pods are Running.


Step 9: Check the Cluster Status

kubectl get cluster cluster-example -n postgres

Expected output:

NAME              AGE   INSTANCES   READY   STATUS                     PRIMARY
cluster-example   5m    3           3       Cluster in healthy state    cluster-example-1

The STATUS column should say Cluster in healthy state. The PRIMARY column tells you which pod is currently the primary.

# Get more details
kubectl describe cluster cluster-example -n postgres | grep -A 20 "Status:"

~ $ kubectl describe cluster cluster-example -n postgres | grep -A 20 “Status:”
Status:
Available Architectures:
Go Arch: amd64
Hash: 4c1c27c307c90acd68cabe32a88c5b5d20ee75e60d8799b75084d4dcf0d0db5e
Go Arch: arm64
Hash: c423938346043d99ad9f86f6046dd4999459deeb10e373f0a13ce733c50bd541
Go Arch: ppc64le
Hash: f37ef9e630b280dacba96de332c8aee27975a94c81a5211af9203d30733eb7c1
Go Arch: s390x
Hash: 10b61223f5f37a91459ba723b21e5f93a4ff5ca270d3cdcea8490d0a77faaf1b
Certificates:
Client CA Secret: cluster-example-ca
Expirations:
Cluster – Example – Ca: 2026-09-21 19:03:35 +0000 UTC
Cluster – Example – Replication: 2026-09-21 19:03:35 +0000 UTC
Cluster – Example – Server: 2026-09-21 19:03:35 +0000 UTC
Replication TLS Secret: cluster-example-replication
Server Alt DNS Names:
cluster-example-rw
cluster-example-rw.postgres

cluster-example-rw.postgres.svc

Status:                True
Type:                  ConsistentSystemID
Last Transition Time:  2026-06-23T19:35:53Z
Message:               Cluster is Ready
Reason:                ClusterIsReady
Status:                True
Type:                  Ready
Last Transition Time:  2026-06-23T19:34:55Z
Message:               Continuous archiving is working
Reason:                ContinuousArchivingSuccess
Status:                True
Type:                  ContinuousArchiving
Last Transition Time:  2026-06-23T19:35:54Z
Message:               velero addon is disabled
Reason:                Disabled
Status:                False
Type:                  k8s.enterprisedb.io/velero
Last Transition Time:  2026-06-23T19:35:54Z
Message:               external-backup-adapter addon is disabled
Reason:                Disabled
Status:                False
Type:                  k8s.enterprisedb.io/externalBackupAdapter
Last Transition Time:  2026-06-23T19:35:54Z
Message:               external-backup-adapter-cluster addon is disabled
Reason:                Disabled
Status:                False
Type:                  k8s.enterprisedb.io/externalBackupAdapterCluster
Last Transition Time:  2026-06-23T19:35:54Z
Message:               kasten addon is disabled
Reason:                Disabled
Status:                False
Type:                  k8s.enterprisedb.io/kasten

Config Map Resource Version:
Metrics:
Postgresql – Operator – Default – Monitoring: 233852
Current Primary: cluster-example-1
Current Primary Timestamp: 2026-06-23T19:34:55.638679Z
Healthy PVC:
cluster-example-1
cluster-example-2
Image: ghcr.io/cloudnative-pg/postgresql:16
Instance Names:
cluster-example-1
cluster-example-2
Instances: 2
Instances Reported State:
cluster-example-1:
Ip: 192.168.82.226
Is Primary: true
Time Line ID: 1

cluster-example-2:

Instances Status:
Healthy:
cluster-example-1
cluster-example-2
Latest Generated Node: 2
License Status:
License Status: Not enforced
Repository Access: false
Valid: false
Managed Roles Status:
Pg Data Image Info:
Image: ghcr.io/cloudnative-pg/postgresql:16
Major Version: 16
Phase: Cluster in healthy state
Pooler Integrations:
Pg Bouncer Integration:
Pvc Count: 2
Read Service: cluster-example-r
Ready Instances: 2
Secrets Resource Version:
Application Secret Version: 233827
Client Ca Secret Version: 233824
Replication Secret Version: 233826
Server Ca Secret Version: 233824
Server Secret Version: 233825
Switch Replica Cluster Status:
System ID: 7654676593876070419
Target Primary: cluster-example-1
Target Primary Timestamp: 2026-06-23T19:08:36.396414Z
Timeline ID: 1
Topology:
Instances:
cluster-example-1:
cluster-example-2:
Nodes Used: 2
Successfully Extracted: true
Write Service: cluster-example-rw
Events:
Type Reason Age From Message
—- —— —- —- ——-
Normal CreatingPodDisruptionBudget 41m cloud-native-postgresql Creating PodDisruptionBudget cluster-example-primary
Normal CreatingServiceAccount 41m cloud-native-postgresql Creating ServiceAccount
Normal CreatingRole 41m cloud-native-postgresql Creating Cluster Role
Normal CreatingInstance 41m cloud-native-postgresql Primary instance (initdb)
Normal CreatingInstance 15m cloud-native-postgresql Creating instance cluster-example-2
~ $


Step 10: Verify the Three Services

CloudNativePG automatically created three services when the cluster came up:

kubectl get svc -n postgres

Expected output:

~ $ kubectl get svc -n postgres
NAME                 TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
cluster-example-r    ClusterIP   10.100.18.171    <none>        5432/TCP   46m
cluster-example-ro   ClusterIP   10.100.11.224    <none>        5432/TCP   46m
cluster-example-rw   ClusterIP   10.100.243.253   <none>        5432/TCP   46m
ServiceRoutes toUse for
cluster-example-rwPrimary onlyYour app’s main R/W connection
cluster-example-roReplicas onlyRead-only queries, analytics
cluster-example-rAny instanceHealth checks, any read


Step 11: Check the Secrets

The operator automatically creates two secrets with connection credentials:

kubectl get secrets -n postgres

Expected output:

~ $ kubectl get secrets -n postgres
NAME                          TYPE                             DATA   AGE
cluster-example-app           kubernetes.io/basic-auth         11     7h1m
cluster-example-ca            Opaque                           2      7h1m
cluster-example-pull          kubernetes.io/dockerconfigjson   1      7h1m
cluster-example-replication   kubernetes.io/tls                2      7h1m
cluster-example-server        kubernetes.io/tls                2      7h1m

The two secrets you’ll use most:

# Get the superuser password
kubectl get secret cluster-example-superuser -n postgres \
  -o jsonpath='{.data.password}' | base64 -d
echo

# Get the app user password
kubectl get secret cluster-example-app -n postgres \
  -o jsonpath='{.data.password}' | base64 -d
echo

Best practice: In production, don’t decode secrets in the terminal. Use AWS Secrets Manager or Vault integration. This is fine for lab use.

[SCREENSHOT: kubectl get secrets -n postgres output] 📸 Screenshot: Operator-managed secrets — superuser and app credentials


Step 12: Connect to PostgreSQL

Let’s connect to the primary and verify everything is working:

# Port-forward the -rw service to your local machine
kubectl port-forward svc/cluster-example-rw 5432:5432 -n postgres &


# Connect with psql

~ $ kubectl get po -n postgres
NAME                READY   STATUS    RESTARTS   AGE
cluster-example-1   1/1     Running   0          6h38m
cluster-example-2   1/1     Running   0          6h38m
~ $ kubectl exec -it cluster-example-1 -n postgres -- psql
Defaulted container "postgres" out of: postgres, bootstrap-controller (init)
psql (16.14 (Debian 16.14-1.pgdg11+1))
Type "help" for help.

postgres=# SELECT pg_is_in_recovery();
 pg_is_in_recovery 
-------------------
 f
(1 row)

Once connected, verify replication status:

-- Check which instance you're on
SELECT pg_is_in_recovery();
-- Should return: f  (false = this is the primary)

-- Check replication lag from primary
SELECT application_name, state, sent_lsn, write_lsn, replay_lsn,
       write_lag, flush_lag, replay_lag
FROM pg_stat_replication;

Expected output:

postgres=# SELECT application_name, state, sent_lsn, write_lsn, replay_lsn,
postgres-#        write_lag, flush_lag, replay_lag
postgres-# FROM pg_stat_replication;
 application_name  |   state   | sent_lsn  | write_lsn | replay_lsn | write_lag | flush_lag | replay_lag 
-------------------+-----------+-----------+-----------+------------+-----------+-----------+------------
 cluster-example-2 | streaming | 0/6000000 | 0/6000000 | 0/6000000  |           |           | 
(1 row)

Both replicas are in streaming state with zero lag. Your HA cluster is working.


Step 13: (Optional) Set Up Monitoring with Prometheus and Grafana

CloudNativePG exposes rich metrics out of the box. Here’s how to set up Prometheus and Grafana for visibility.

Install the Kube-Prometheus stack:

# Add Prometheus community Helm repo
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

# Install kube-prometheus-stack with CloudNativePG sample config
helm upgrade --install prometheus-community \
  prometheus-community/kube-prometheus-stack \
  -f https://raw.githubusercontent.com/EnterpriseDB/docs/main/product_docs/docs/postgres_for_kubernetes/1/samples/monitoring/kube-stack-config.yaml

Deploy a cluster with PodMonitor enabled:

kubectl apply -f - <<EOF
apiVersion: postgresql.k8s.enterprisedb.io/v1
kind: Cluster
metadata:
  name: cluster-with-metrics
  namespace: postgres
spec:
  instances: 3
  storage:
    size: 10Gi
  monitoring:
    enablePodMonitor: true
---
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: cluster-with-metrics
  namespace: postgres
spec:
  selector:
    matchLabels:
      k8s.enterprisedb.io/cluster: cluster-with-metrics
  podMetricsEndpoints:
  - port: metrics
EOF

Please note the output executed in cloud shell for the reference

~ $ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
“prometheus-community” has been added to your repositories


~ $ helm repo update
Hang tight while we grab the latest from your chart repositories…
…Successfully got an update from the “edb” chart repository
…Successfully got an update from the “prometheus-community” chart repository
Update Complete. ⎈Happy Helming!⎈
~ $ helm upgrade –install prometheus-community \

prometheus-community/kube-prometheus-stack \
-f https://raw.githubusercontent.com/EnterpriseDB/docs/main/product_docs/docs/postgres_for_kubernetes/1/samples/monitoring/kube-stack-config.yaml
Release “prometheus-community” does not exist. Installing it now.
NAME: prometheus-community
LAST DEPLOYED: Wed Jun 24 02:22:02 2026
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
kube-prometheus-stack has been installed. Check its status by running:
kubectl –namespace default get pods -l “release=prometheus-community”

Get Grafana ‘admin’ user password by running:

kubectl –namespace default get secrets prometheus-community-grafana -o jsonpath=”{.data.admin-password}” | base64 -d ; echo

Access Grafana local instance:

export POD_NAME=$(kubectl –namespace default get pod -l “app.kubernetes.io/name=grafana,app.kubernetes.io/instance=prometheus-community” -oname)
kubectl –namespace default port-forward $POD_NAME 3000

Get your grafana admin user password by running:

kubectl get secret –namespace default -l app.kubernetes.io/component=admin-secret -o jsonpath=”{.items[0].data.admin-password}” | base64 –decode ; echo

Visit https://github.com/prometheus-operator/kube-prometheus for instructions on how to create & configure Alertmanager and Prometheus instances using the Operator.
~ $ kubectl apply -f – <<EOF

apiVersion: postgresql.k8s.enterprisedb.io/v1
kind: Cluster
metadata:
name: cluster-with-metrics
namespace: postgres
spec:
instances: 3
storage:
size: 10Gi
monitoring:

enablePodMonitor: true

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: cluster-with-metrics
namespace: postgres
spec:
selector:
matchLabels:
k8s.enterprisedb.io/cluster: cluster-with-metrics
podMetricsEndpoints:

  • port: metrics
    EOF
    Warning: spec.monitoring.enablePodMonitor is deprecated and will be removed in a future release. Please migrate to manually managing your PodMonitor resources. Set this field to false and create a PodMonitor resource for your cluster as described in the documentation.
    cluster.postgresql.k8s.enterprisedb.io/cluster-with-metrics created
    podmonitor.monitoring.coreos.com/cluster-with-metrics created

Patch to expose load balancer for both Grafana and Prometheus

~ $ kubectl get svc
NAME                                      TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                      AGE
alertmanager-operated                     ClusterIP   None             <none>        9093/TCP,9094/TCP,9094/UDP   2m9s
kubernetes                                ClusterIP   10.100.0.1       <none>        443/TCP                      30h
prometheus-community-grafana              ClusterIP   10.100.225.199   <none>        80/TCP                       2m14s
prometheus-community-kube-alertmanager    ClusterIP   10.100.30.122    <none>        9093/TCP,8080/TCP            2m14s
prometheus-community-kube-operator        ClusterIP   10.100.22.141    <none>        443/TCP                      2m14s
prometheus-community-kube-prometheus      ClusterIP   10.100.132.211   <none>        9090/TCP,8080/TCP            2m14s
prometheus-community-kube-state-metrics   ClusterIP   10.100.109.245   <none>        8080/TCP                     2m14s
prometheus-operated                       ClusterIP   None             <none>        9090/TCP                     2m9s
~ $ kubectl patch svc prometheus-community-grafana -n default -p '{"spec": {"type": "LoadBalancer"}}' 
service/prometheus-community-grafana patched
~ $ kubectl patch svc prometheus-community-kube-prometheus -n default -p '{"spec": {"type": "LoadBalancer"}}'
service/prometheus-community-kube-prometheus patched
~ $ kubectl get secret prometheus-community-grafana -n default -o jsonpath="{.data.admin-password}" | base64 --decode ; echo
prom-operator
~ $ kubectl get svc -n default | grep LoadBalancer
prometheus-community-grafana              LoadBalancer   10.100.225.199   a9d817ea571da41a48f6245acf4ee6da-1298121984.ca-central-1.elb.amazonaws.com   80:30878/TCP                    4m48s
prometheus-community-kube-prometheus      LoadBalancer   10.100.132.211   a36ac016f40dc48e6a7ef39aedc8fd99-60276688.ca-central-1.elb.amazonaws.com     9090:31151/TCP,8080:30576/TCP   4m48s

Access Grafana:


# Open http://10.100.225.199:3000
# Username: admin  |  Password: prom-operator

Import the CloudNativePG Grafana dashboard:

  1. In Grafana → Dashboards → New → Import
  2. Upload the file from: https://raw.githubusercontent.com/EnterpriseDB/docs/main/product_docs/docs/postgres_for_kubernetes/1/samples/monitoring/grafana-dashboard.json

The Enterprise Dashboard Hack

Because you are running the commercial edb-postgres-advanced image, your database outputs metrics starting with cnp_. However, the upstream dashboards search for cnpg_. 

Instead of writing a dashboard from scratch, download the open-source one and instantly swap the prefixes using sed: 

Bash 

# Download and patch the dashboard 
curl -s https://raw.githubusercontent.com/cloudnative-pg/grafana-dashboards/main/charts/cluster/grafana-dashboard.json | sed ‘s/cnpg_/cnp_/g’ > edb-dashboard.json 
 
# Print the JSON to copy it 
cat edb-dashboard.json 
 

Final Steps in Grafana: 

  1. Log into Grafana using your public IP, admin, and the password you extracted. 
  2. Go to Dashboards -> Import
  1. Paste the entire JSON blob from edb-dashboard.json into the “Import via panel json” text box. 
  2. Click Load, select your Prometheus data source, and click Import
  3. Select edb-workload and primary-db from the dropdowns at the top. 

Save this to your personal runbook! If you ever need to rebuild this cluster or deploy it to a new region, running these commands top-to-bottom will get your metrics flowing in under 5 minutes. 

@ Grafana 
Dashboards > CloudNativePG 
Q Sear 
ctrl+k + v 2 
Home 
Edit Export ~ Share 
Bookmarks 
A Starred 
Datasource Prometheus 
Operator Namespace @ ~ Database Namespace @ edb-workload ~ Cluster @ primary-db 
Instances @ All x X v 
Starre 
« Last 7 days > > Q Refresh 30s 
Dashboard 
Alerts 
Health 
Overview 
Storage 
Backups 
Playlists 
LastFailedArchiveTime 
Replication -4 
jealth 
Healthy 
Last failover 
PS @ 
CPU Utili ... 
Memory ... 
plication Lag 
Vrite Lad 
Volume Spac 
Last Base Backup ® 
Snapshots 
Library panel 
for 10h 14m 1s 
3 instances 
Library Parlers 
20 hours ago 
Os 
ared dashboar 
Shared dash 
Connections 
Healthy 
24.7 
IMso od 
0.368% 
ast archived WAL 
Explore 
Version 
lush Lag 
eplay La 
. Drilldo 
27 seconds 
Drilldov 
Alerting 
ealthy 
No data 
No data 
18.1 
Database Size 
First Recoverability Point 
tal 0.03 
Total 272 MiB 
MIB 
Os 
Alerting 
60.03 M 
)Connections 
N/A 
Server Health 
3 Administration 
Instance 
Status 
Clustering / replicas 
Zone 
Connecti 
Max Connections 
Wraparound 
Started 
Version 
0 hours ago 
Up 
Yes 
No data 
18.1 
primary-db- 
Yes 
Yes 
04/01 
04/03 
4/05 
04/07 
13 
3% 
20 hours ago 
2 
- Last: 9 Mean: 8. 
hours ago 
hours ago 
rimary-db-2 
Up 
Yes 
Yes 
Yes 
No data 
04/01 
04/05 
04/07 
13 
20 hours 
18.1 
- - Last: 3 Mean: 25 
1% 
20 hours ago 
Up 
20 hours ago 
rimary-db-3 
les 
Yes 
Yes 
o data 
13 
20 hours ago 
18.1 
0 
Last: 3 Mean: 3 7 
04/05 
04/07 
1% 
20 hours ag 
Configuration 
(17 panels) 
ration (17 pane 
Operational Stats 
CPU Usage 
Memory Usage (container memory working set) 
24 MIE 
92 MIE 
0.1 
60 Mil 
28 MIE 
6 MIE 
MIE 
2 MIE 
04/01 00:00 
- primary-db-1 - primary-db-2 - prin 
04/03 00:00 
04/04 00:00 
4/05 00:00 
04/06 00-00 
04/07 00:00 
04/01 00:00 
04/02 00:00 
04/04 00:00 
04/05 00:00 
4/06 00:00 
primary-dhetsalmone 
04/03 00:00 
04/07 00:00 
mary-db-2 
- primary-db- 
primarvadh. 
Session States 
04/01 00:00 
04/01 08:00 
04/01 00:00 04/01 08:00 
04/01 16:00 
DAJ01 16:00 
04/02 00:00 
04/02 16:00 04/03 00:00 
00 04/02 08:00 04/02 16:00 04/03 00:00 04/03 08:00 
04/03 16:00 
/04 00:00 04/04 08:00 
04/04 16:00 04/05 00:00 04/05 08:00 
04/05 16:00 
04/06 00:00 
04/06 08:00 
04/06 16:00 04/07 00:00 
04/07 08:00 
total (primary-db-1) - total (primary-db-2) - total (primary-db-3) - active (primary-db-1) - active (primary-db-2) - active (primary-db-3) 
ransactions [5m] 
Longest Transaction 
.67 mins 
10 
10 
1 min 
O 
04/01 00:00 
04/02 00:00 
04/03 00:00 
04/04 00:00 
04/05 00:00 
04/06 00:00 
04/07 00:00 
committed (primary-db-1) - committed Isel 
04/01 00:00 
04/02 00:00 
04/03 00:00 
04/04 00:00 
04/05 00:00 
04/06 00:00 
04/07 00:00 
- rolled back Iprimary th, 21 
but _primary.db 
- primary.dh. 
Deadlocks [ 
ml A 
Blocked Queries 
00 
04/01 00:00 
04/02 00:00 
4/03 00:00 
04/04 00:00 
04/05 00:00 
DAJ06 00:00 
04/07 00:00 
04/01 00:0 
04/03 00:00 
04/05 00:00 
04/06 00:00 
04/07 00:00 
unt (primary-db-3 
nary-db-2) _ count (primary-db-2 
primary-db-1 
primary-db-1 - primary-db-1 - primary-db.2 
-db-3 - primar 
primary-db-2 - primary-db-2 
Storage & I/O 
Volume Space Usage: PGDATA and WAL 
Volume Inode Usage: PGDATA and WA 
0.368% 
0.336% 
0.273% 
0.06% 
0.06% 
0.06% 
primary-db-1 
primary-db-2 
primary-db-3 
primary-db- 
primary-db-2 
primary-db-3 
Volume Space Usage: Tablespaces 
No data 
Tuple I/O [5m] @ 
Block I/O [5m] @ 
3221 
80 
40 
20 
04/01 00:00 
04/02 00:00 
(primary-db-1). - 
04/03 00:00 
mary-dh- 
M . 
04/07 00:00 
- hit (primary-db-1) - hit (primary-db-1) = hit (primary-db-1) - hit (primary-db-3) ~ hit (primary-db-3) - hit (primary-db-3)- 
hit (primary-do -2) 
04/01 00:00 
04/03 00:00 
04/04 00:00 
04/05 00:00 
04/06 00:00 
04/07 00:00 
- hit (primary-db-2) - hit (primary-db-2) - 
y-db-2) - read (primary-db-1) - read (primary-db-1) - read (primary-db-1) - read (primary-db-3) - read (primary-db-3) 
deleted - inserted 
- fetchedPatused 
Updated 
- read (primary-db-3) - read (primary-db-2) - read (primary-db-2) - read (primary-db-2) 
Database Size 
Temp Bytes [5m] 
30 MB 
0 ME 
O M 
40 B 
20 B 
OB 
OB 
04/01 00:00 
04/04 00:00 
04/05.00:00 
04/06 00:00 
04/07 00:00 
04/01 00:00 
04/02 00:00 
04/03 00:00 
primary-dh.a . 
04/04 00:00 
04/05 00:00 
04/06 00:00 
- primary- 
2 - primary-db-2 - primary-db-2 - primary-db-2 
04/07 00:00 
-. edb 
ary-db-1 - primary-db-1 
- primary-db-1 - primary-db-3 - primary-db-3 - primary-db-3 - primary-db-2 
v Write Ahead Log 
WAL Segment Archive Statu 
st Archive Ag

Common Errors and Fixes

Error 1: ImagePullBackOff on operator pod

Symptom:

NAME                                                    READY   STATUS             RESTARTS
postgresql-operator-controller-manager-xxx              0/1     ImagePullBackOff   0

Cause: The edb-pull-secret is missing or has an incorrect token.

Fix:

# Delete the old secret and recreate it
kubectl delete secret edb-pull-secret -n postgresql-operator-system

kubectl create secret -n postgresql-operator-system docker-registry edb-pull-secret \
  --docker-server=docker.enterprisedb.com \
  --docker-username=k8s \
  --docker-password="$EDB_SUBSCRIPTION_TOKEN"

# Restart the operator pod
kubectl rollout restart deployment \
  postgresql-operator-controller-manager \
  -n postgresql-operator-system

Error 2: Cluster stuck in “Setting up primary instance”

Symptom:

kubectl get cluster cluster-example -n postgres
NAME              STATUS                          PRIMARY
cluster-example   Setting up primary instance

After 5+ minutes, still not progressing.

Cause: Usually a storage issue — the PVC can’t be provisioned. Often the StorageClass doesn’t exist or the EBS CSI driver isn’t installed.

Fix:

# Check PVC status
kubectl get pvc -n postgres

# Check events on the stuck PVC
kubectl describe pvc cluster-example-1 -n postgres | grep -A 10 Events

# Verify your StorageClass exists
kubectl get storageclass

# If using EKS, ensure EBS CSI driver is installed
kubectl get pods -n kube-system | grep ebs

If no StorageClass exists, install the EBS CSI driver via AWS Add-ons in the EKS console, then re-apply your cluster YAML.


Error 3: Webhook error on GKE (not EKS — but documented)

Symptom:

Error: failed to call webhook "vmutate.kb.io": ...connection refused

Cause: On GKE only — firewall blocks traffic to port 9443. Fix: Not applicable for AWS EKS. On EKS, security groups automatically allow inter-node traffic.


Key Takeaways

✅ CloudNativePG requires an EDB pull secret before installation — the token is free and takes 2 minutes to get.

✅ The operator installs in its own postgresql-operator-system namespace and watches all namespaces by default.

✅ A Cluster resource with instances: 3 automatically creates one primary and two streaming replicas — no manual replication setup needed.

✅ Three services (-rw, -ro, -r) are created automatically. Your app should always connect via -rw for read/write and -ro for read-only queries.

✅ Both connection secrets (-superuser, -app) are automatically created and rotated by the operator — never hardcode PostgreSQL passwords.


Test Your Knowledge

Ready to test what you’ve learned? Take the free quiz:

👉 PostgreSQL on Kubernetes Quiz → gradeupnow.in/postgres-replication-quiz/

20 questions · Instant feedback · Detailed explanations · Free


What’s Next

This post is part of the PostgreSQL on Kubernetes (CloudNativePG) series:

#PostStatus
1CloudNativePG Architecture on AWS EKS✅ Published
2Installing CloudNativePG on AWS EKS — Step by Step📍 You are here
3PostgreSQL Configuration & Pod Tuning on Kubernetes⬜ Coming next week
4Bootstrap Methods — initdb, Recovery, pg_basebackup⬜ Coming soon

In Post 3, we go deeper into configuration — how to tune postgresql.conf parameters through the Cluster spec, configure resource limits and requests for pods, and set up WAL storage as a separate volume.

👉 [Next Post: PostgreSQL Configuration & Pod Tuning on Kubernetes → coming next week]


References


Found this helpful? Share it with your DBA team! Questions? Drop them in the comments below.


Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top