Every app that needs PostgreSQL gets its own CNPG Cluster in the app's namespace. Don't deploy a standalone Postgres container or StatefulSet, and don't share a cluster between apps. The operator (cnpg-system) handles provisioning, failover, credentials and backups.
The example below is for an app called myapp in the namespace myapp. Replace both names. It uses Terraform kubectl_manifest, which is how every CNPG resource in the cluster is managed.
resource "kubectl_manifest" "myapp_db_cluster" {
yaml_body = <<-YAML
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: myapp-db
namespace: myapp
spec:
# Primary + streaming replica. Each volume is strict-local with 1 replica,
# so HA comes from Postgres replication, not from Longhorn. Required
# anti-affinity puts the two instances on different storage nodes.
instances: 2
description: "Database for myapp"
enableSuperuserAccess: false
affinity:
enablePodAntiAffinity: true
podAntiAffinityType: required
topologyKey: kubernetes.io/hostname
# Required for longhorn-database: DB pods must run on the storage nodes.
nodeSelector:
tier: pv-storage
tolerations:
- key: dedicated-purpose
operator: Equal
value: storage
effect: NoSchedule
storage:
size: 5Gi
storageClass: longhorn-database
bootstrap:
initdb:
database: myapp
owner: myapp
# Cold snapshots pushed to the NAS (off-cluster). There's no WAL archive,
# so online snapshots can't be restored. CNPG snapshots the standby,
# so the primary keeps serving.
backup:
volumeSnapshot:
className: longhorn-backup
online: false
labels:
app: myapp-db
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 1Gi
YAML
# longhorn-database uses a Retain policy. This guard stops a stray destroy from
# taking the cluster (and its PVCs) with it. To decommission, remove this block
# first, apply, then remove the resource.
lifecycle {
prevent_destroy = true
}
}
resource "kubectl_manifest" "myapp_db_backup" {
yaml_body = <<-YAML
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: myapp-db-daily
namespace: myapp
spec:
schedule: "0 0 0 * * *" # 00:00 UTC daily. CNPG cron has 6 fields (seconds first)
method: volumeSnapshot
# longhorn-backup deletes the NAS copy when a Backup object is deleted.
# "none" stops a deleted ScheduledBackup from cascade-deleting every backup.
backupOwnerReference: none
cluster:
name: ${kubectl_manifest.myapp_db_cluster.name}
YAML
}
You don't need to configure retention. CNPG's own retentionPolicy does nothing for snapshot backups, so the platform runs a nightly pruner (cnpg-backup-pruner in cnpg-system, 03:00 UTC). For every cluster it keeps the newest 7 daily backups plus the newest backup from each of the last 4 weeks, and deletes older scheduled backups along with their NAS copies. It also removes failed scheduled backups older than 7 days. It never touches manual backups, or backups of a cluster that no longer exists.
To change this for one cluster, set these on the Cluster metadata:
| Key | Kind | Effect |
|---|---|---|
backup.rachelski.internal/prune: "false" |
label | Turns pruning off for this cluster |
backup.rachelski.internal/keep-daily: "<n>" |
annotation | Changes the daily count (1 or more) |
backup.rachelski.internal/keep-weekly: "<n>" |
annotation | Changes the weekly count (0 or more) |
If you need a cluster's full backup history, for example while investigating a data problem or partway through a restore, turn pruning off before the old backups age out. If a cluster's newest backup is more than 48 hours old, the pruner leaves that cluster alone and the job fails. That way a run of failed backups never eats into the ones that are left.
CNPG creates these for you. No database password goes into Vault:
| Object | Name | Use |
|---|---|---|
| Service | myapp-db-rw |
Read/write, always points at the current primary. Use this one. |
| Service | myapp-db-ro |
Read-only, replicas only. Only for read-heavy workloads that can tolerate lag. |
| Secret | myapp-db-app |
App credentials. Keys: username, password, host, port, dbname, uri, jdbc-uri, pgpass |
host in the Secret is already myapp-db-rw, so after a failover the app reconnects to the new primary on its own.
Raw Deployment / Terraform kubernetes_deployment_v1:
env {
name = "DB_HOST"
value_from {
secret_key_ref {
name = "myapp-db-app"
key = "host"
}
}
}
env {
name = "DB_PASSWORD"
value_from {
secret_key_ref {
name = "myapp-db-app"
key = "password"
}
}
}
# ...same for username, dbname, port. Or use the `uri` key if the app takes a DATABASE_URL.
Helm chart with an "existing secret" option (Nextcloud shown):
externalDatabase:
type: postgresql
existingSecret:
enabled: true
secretName: myapp-db-app # generated by CNPG
usernameKey: username
passwordKey: password
hostKey: host
databaseKey: dbname
Add depends_on = [kubectl_manifest.myapp_db_cluster] to the app's helm_release or Deployment so the Secret exists before the pods start.
Cluster per app, named <app>-db, in the app's namespaceinstances: 2 with required anti-affinity on kubernetes.io/hostnamestorageClass: longhorn-database with nodeSelector: tier: pv-storage and the dedicated-purpose=storage tolerationenableSuperuserAccess: falsebackup.volumeSnapshot using longhorn-backup and online: falseScheduledBackup with backupOwnerReference: nonelifecycle { prevent_destroy = true } on the Cluster<app>-db-app Secret and the -rw Service. No hard-coded credentials, no Vault entrylonghorn-database.className: longhorn for backups. That's a local snapshot only, with no off-cluster copy.backupOwnerReference to self or cluster.instances: 1 unless you've accepted downtime whenever a storage node is drained. With a strict-local volume, that instance can't move to another node.