Running production Postgres with the CNPG operator, Helm, External Secrets and S3 backups — lessons from a real deployment
The approach
CloudNativePG (CNPG) is a Kubernetes operator that runs Postgres as a first-class resource: failover, backups, and monitoring are declarative. The key decision here is everything goes through Helm + GitOps — no raw Cluster manifests, no kubectl apply, no manual helm upgrade. The CNPG cluster chart is a subchart of the app's own chart:
postgresql: key in the app's values.yaml, versioned next to the app that uses it. Argo CD syncs both together — replicating the setup to a new environment means copying a values block, not re-running a runbook.
The moving parts
CNPG Cluster
Instances, storage, resources, affinity and monitoring, all in values. The operator creates a read-write Service at <name>-rw.<namespace>:5432 — that's the hostname apps connect to.
External Secrets
Two ExternalSecrets pull the app user and superuser credentials from the cloud secrets manager as kubernetes.io/basic-auth Secrets, referenced by initdb and superuserSecret. Apps get their connection URL the same way.
S3 via IAM role
WAL archiving plus scheduled base backups to S3, gzip-compressed and AES256-encrypted, with a retention policy. No access keys: the Postgres service account assumes an IAM role (IRSA on EKS).
NetworkPolicy + monitoring
A NetworkPolicy restricts ingress to port 5432. A PodMonitor labeled for the Prometheus release exposes CNPG's built-in metrics — replication lag, WAL, transactions.
Secrets layout
One secrets-manager entry per environment holds everything the database stack needs:
| Key | What it is |
|---|---|
postgres-username / postgres-password |
The application user — becomes the initdb owner of the app database. |
postgres-root-username / postgres-admin-password |
The superuser, wired in via superuserSecret. |
POSTGRES_URL |
Full connection string apps consume, e.g. postgres://app_user:<pw>@myapp-postgresql-rw.myapp:5432/app_db. Each component that needs the database pulls it through its own ExternalSecret. |
@ broke connection-string parsing in one component — silently. The URL parser split on the wrong @ and the failure surfaced far from the cause. Long random alphanumeric strings avoid the whole class of bug.
Backups without access keys
The backup config in values is short — the interesting part is the IAM plumbing behind inheritFromIAMRole:
IRSA instead of keys. An IAM role trusts the cluster's OIDC provider, scoped to the exact service account (system:serviceaccount:<ns>:<cluster-name>). The role ARN goes on the Postgres pod via serviceAccountTemplate annotations.
Least-privilege S3 policy. ListBucket on the bucket, object read/write/delete only under the /cnpg/* prefix.
CNPG schedules have six fields. 0 0 3 * * * is 3:00 AM — the first field is seconds. Copy-pasting a five-field cron expression silently means something else.
Validating the deployment
The kubectl cnpg plugin does most of the work. The one-shot psql pod is the fastest end-to-end test — it validates the secret, the URL, DNS and the NetworkPolicy in one command:
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
| Cluster pods stuck Pending | CNPG operator isn't installed in the cluster. The chart only renders the Cluster resource — the operator is a separate install. |
Init:Error during bootstrap |
The credentials Secret is missing or malformed. Check the ExternalSecret status and the secrets-manager keys it maps. |
| Backups failing | IRSA trust policy, S3 permissions, or the service-account annotation. Verify all three — the error rarely says which one. |
| App CrashLoopBackOff on startup | The connection URL wasn't injected — the component's ExternalSecret is missing the POSTGRES_URL entry. |
| Connection refused | Wrong hostname. Apps must use the operator's read-write Service: <cluster>-rw.<namespace>:5432. |
| Auth or parsing errors with a correct password | Special characters in the password breaking URL parsing. Regenerate alphanumeric-only. |