GitOps Deployment of CloudNativePG with ArgoCD
Deploying the CloudNativePG operator, Barman Cloud Plugin, and a production Postgres cluster declaratively - sync waves, webhook-defaulting drift, and custom health checks
Introduction
The previous guide built a production CloudNativePG cluster with daily backups to Cloudflare R2 by applying manifests with kubectl. This one covers the same setup managed declaratively through ArgoCD instead - and two things about CloudNativePG specifically make that non-trivial: installation has a strict ordering dependency (CRDs and the plugin must exist before a Cluster can reference them), and CloudNativePG’s own admission webhook mutates .spec with defaults at apply time, which left unaddressed shows up as a permanently OutOfSync Application in ArgoCD.
Repository Layout and Sync Waves
Three logical groups of resources, each with a real ordering dependency on the one before it:
- The CloudNativePG operator and Barman Cloud Plugin (cluster-scoped CRDs and controllers)
- The
ObjectStoreand its credentials Secret (namespaced, but must exist before theClusterreferences them) - The
ClusterandScheduledBackup(depend on both of the above)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cnpg-operator
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-2"
spec:
project: default
source:
repoURL: https://github.com/example/platform-gitops.git
targetRevision: main
path: cnpg/operator
destination:
server: https://kubernetes.default.svc
namespace: cnpg-system
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
cnpg/operator in the source repo should hold a vendored, version-pinned copy of the operator manifest (cnpg-1.30.0.yaml) and the plugin manifest, not a kubectl apply -f <upstream URL> reference - ArgoCD needs an actual file in Git to diff against, and pointing at a moving upstream release URL means every future upstream release silently changes what “in sync” means for your Application without a corresponding Git commit. Pin the version, commit the file, and bump it deliberately (the same discipline as pinning imageName on the Cluster itself).
sync-wave: "-2" (and "-1" for cert-manager if it isn’t already cluster infrastructure managed elsewhere) ensures the operator and plugin are fully reconciled before ArgoCD attempts to apply anything that depends on their CRDs - a Cluster or ObjectStore applied before its CRD exists simply fails, and ArgoCD’s own dependency-free default sync order doesn’t guarantee the ordering CloudNativePG needs.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cnpg-prod-postgres
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
project: default
source:
repoURL: https://github.com/example/platform-gitops.git
targetRevision: main
path: cnpg/prod-postgres
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
The cnpg/prod-postgres path holds the ObjectStore, Cluster, and ScheduledBackup manifests from the previous guide - a single Application is fine here since they’re small and tightly coupled, but per-resource sync-wave annotations within that path (ObjectStore and its Secret at wave 0, Cluster and ScheduledBackup at wave 1) still matter, since ArgoCD applies resources within one Application in wave order too.
Keeping R2 Credentials Out of Git
The r2-backup-creds Secret from the previous guide can’t be committed in plaintext. This isn’t a CloudNativePG-specific problem - it’s the same tradeoff covered in the Kubernetes secrets management guide on this site: either encrypt the Secret at rest in Git with Sealed Secrets (decrypted only by a controller running in-cluster), or keep the real value entirely out of Git with the External Secrets Operator pulling from a real secrets manager (AWS Secrets Manager, Vault, etc.) and materializing a Kubernetes Secret at sync time. Either approach lets the reference to the credential live in Git and go through the same ArgoCD-managed sync as everything else, while the actual R2 access key never does.
Handling CloudNativePG’s Webhook-Defaulting Drift
CloudNativePG’s Cluster resource has a mutating admission webhook that fills in defaults on .spec itself, not just .status - confirmed directly in the operator’s source (Cluster.Default() in api/v1/cluster_defaults.go). Concretely, if left unset in your manifest, the webhook sets:
.spec.imageNameto the operator’s currently configured default Postgres image.spec.postgresql.parametersto a fully expanded set of sanitized Postgres configuration parameters - not just the ones you wrote, the complete resolved set.spec.affinity.podAntiAffinityTypetoPreferred, once pod anti-affinity is enabled.spec.replicationSlots.highAvailabilityto{enabled: true, slotPrefix: "_cnpg_"}, and.spec.replicationSlots.synchronizeReplicas.enabledtotrue
Because these land on .spec (not .status, which ArgoCD’s default diff mostly ignores for a well-behaved CRD), the live object’s spec ends up with more fields populated than what’s committed in Git - and ArgoCD will report that Application as permanently OutOfSync, since its diff is comparing your committed manifest against a live object the webhook has already expanded.
Two ways to handle it, and the first is the better default:
Commit the resolved values explicitly. Set imageName (as the previous guide already recommends, for its own reasons), and if you rely on any non-default postgresql.parameters, write them out in full in Git rather than leaving the field unset. This keeps Git as the actual source of truth for what’s running, which is the point of GitOps in the first place - it just means being explicit about values the webhook would otherwise silently fill in for you.
Or, tell ArgoCD to ignore specific fields, for values you’re deliberately fine leaving to the operator’s defaults:
spec:
ignoreDifferences:
- group: postgresql.cnpg.io
kind: Cluster
jsonPointers:
- /spec/replicationSlots
- /spec/affinity/podAntiAffinityType
Don’t reach for ignoreDifferences reflexively on every field that shows up in a diff, though - run argocd app diff cnpg-prod-postgres first and confirm each field you’re about to ignore is genuinely webhook-defaulted noise, not a real drift you’d want to know about (someone running kubectl edit directly against the cluster, for instance, which selfHeal: true would otherwise catch and revert).
Custom Health Checks for CloudNativePG Resources
ArgoCD doesn’t know how to assess the health of a Cluster or Backup out of the box - without a health check, a Cluster just shows Synced (the manifest was applied) with no signal about whether Postgres is actually up and replicated. ArgoCD supports Lua-based custom health checks per resource kind, configured in the argocd-cm ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
resource.customizations.health.postgresql.cnpg.io_Cluster: |
hs = {}
if obj.status ~= nil and obj.status.readyInstances ~= nil then
if obj.status.readyInstances == obj.spec.instances then
hs.status = "Healthy"
hs.message = obj.status.phase or "All instances ready"
return hs
end
hs.status = "Progressing"
hs.message = string.format("%d/%d instances ready", obj.status.readyInstances, obj.spec.instances)
return hs
end
hs.status = "Progressing"
hs.message = "Waiting for status"
return hs
resource.customizations.health.postgresql.cnpg.io_Backup: |
hs = {}
if obj.status ~= nil and obj.status.phase ~= nil then
if obj.status.phase == "completed" then
hs.status = "Healthy"
elseif obj.status.phase == "failed" then
hs.status = "Degraded"
else
hs.status = "Progressing"
end
hs.message = obj.status.phase
return hs
end
hs.status = "Progressing"
hs.message = "Waiting for backup to start"
return hs
With this in place, a Cluster stuck below its declared instance count or a Backup that failed shows up as Degraded directly in the ArgoCD UI and CLI - the same place you already look for sync status - rather than requiring a separate kubectl cnpg status check to notice.
Alerting on Backup Failures
Combined with the Notifications engine, a custom health check that surfaces Degraded on a failed Backup also means the existing on-health-degraded trigger (from the built-in notifications catalog) fires for backup failures specifically, once subscribed on the Application that owns the Backup resources - no bespoke trigger needed, since the health check above already does the work of classifying “backup failed” as a health state ArgoCD’s notifications system already knows how to watch.
Declarative Role Management
For anything beyond the single application user created by bootstrap.initdb, CloudNativePG’s DatabaseRole custom resource is the officially recommended approach for GitOps workflows specifically - it decouples role lifecycle from the Cluster resource itself, so adding a new read-only reporting role doesn’t require touching the Cluster manifest at all:
apiVersion: postgresql.cnpg.io/v1
kind: DatabaseRole
metadata:
name: reporting-role
namespace: production
spec:
cluster:
name: prod-postgres
name: reporting
login: true
createdb: false
databaseRoleReclaimPolicy: retain
inRoles:
- pg_monitor
passwordSecret:
name: prod-postgres-reporting-user
databaseRoleReclaimPolicy: retain (the default) leaves the Postgres role in place if this manifest is ever deleted from Git - the safer choice for anything beyond a genuinely ephemeral role, mirroring how PersistentVolumeReclaimPolicy protects against accidental data loss on the storage side.
Best Practices
- Vendor and pin the operator/plugin manifests in Git rather than pointing an Application’s source at a live upstream release URL - reproducibility and an honest diff both depend on Git being the actual source of truth for the exact version deployed.
- Set sync-waves based on real CRD/resource dependencies, not arbitrarily - operator and plugin first,
ObjectStore/Secret next,Cluster/ScheduledBackuplast, matching the orderkubectl applywould need to succeed manually. - Prefer committing explicit values over blanket
ignoreDifferencesfor CloudNativePG’s webhook-defaulted fields - it keeps Git as the real source of truth and avoids accidentally masking genuine drift alongside the expected noise. - Add the custom health checks before relying on ArgoCD’s UI/notifications for cluster or backup status - without them, ArgoCD only ever tells you the manifest was applied, never whether Postgres is actually healthy.
- Manage roles beyond the app user with
DatabaseRoleresources, not ad-hocpsqlcommands against a running cluster - it’s the difference between role grants being reviewable in a PR and being invisible until someone goes looking.
Conclusion
CloudNativePG under ArgoCD is a real GitOps workflow, not just “the same YAML, applied differently” - the parts that need actual thought are the install-order dependency on CRDs, the webhook-defaulting drift ArgoCD will otherwise report as permanent OutOfSync, and giving ArgoCD a way to know whether the Postgres cluster underneath is actually healthy rather than just “applied.”