CNPG Database Management
Guide to provisioning and managing PostgreSQL databases using CloudNative-PG (CNPG) in the homelab Kubernetes platform. The platform supports both a shared multi-tenant cluster and dedicated per-application clusters.
Architecture Overview
┌──────────────────────────────────────────────────────────────────────┐ │ Database Architecture │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ database namespace │ │ ┌────────────────────────────────────┐ │ │ │ Shared Platform Cluster │ │ │ │ ├─ platform-0 (primary) │ ◄── cnpg-platform-superuser │ │ │ ├─ platform-1 (replica) │ (stays in database ns) │ │ │ └─ platform-2 (replica) │ │ │ │ │ │ │ │ Managed Roles (CNPG-controlled): │ │ │ │ ├─ authelia, lldap, sonarr, ... │ ◄── <app>-role-password │ │ │ │ (secret-generator) │ │ │ Database CRDs (CNPG-controlled): │ │ │ │ ├─ authelia, lldap, sonarr-main, │ │ │ │ │ sonarr-log, radarr-main, ... │ │ │ │ │ │ │ │ platform-pooler-rw (PgBouncer) │ ◄── Apps connect here │ │ └────────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────────┐ │ │ │ Dedicated Cluster (e.g. Immich) │ │ │ │ ├─ immich-database-0 (primary) │ ◄── immich-database-app │ │ │ └─ immich-database-1 (replica) │ (CNPG-generated) │ │ └────────────────────────────────────┘ │ │ │ │ ──── kubernetes-replicator ────────────────────────────────────── │ │ │ │ app namespaces (authelia, zipline, immich, ...) │ │ ┌─────────────────────────────────────┐ │ │ │ Replica secrets: │ │ │ │ ├─ <app>-db-credentials │ (from database/<app>-role- │ │ │ │ │ password via replicator) │ │ │ └─ cnpg-immich-database-app │ (from database/immich- │ │ │ │ database-app) │ │ └─────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────┘
Decision Tree: Shared vs Dedicated Cluster
App needs a PostgreSQL database? │ ├─ Standard workload (generic SQL, no special extensions)? │ └─ Use shared platform cluster │ Connect via platform-pooler-rw service │ (See: Shared Cluster Workflow below) │ ├─ Needs custom extensions (vector search, PostGIS, etc.)? │ └─ Use dedicated cluster │ Custom imageName with extensions pre-installed │ (See: Dedicated Cluster Workflow below) │ ├─ Needs isolation for performance or data sensitivity? │ └─ Use dedicated cluster │ Independent resources, storage, and backup │ └─ Unclear? └─ Start with shared cluster Migrate to dedicated if needed later
Shared Platform Cluster
The shared cluster is defined at kubernetes/platform/config/database/ and deployed to all clusters via the platform Kustomization. It uses CNPG managed roles and Database CRDs to declaratively provision databases and users -- no init containers or superuser access needed in app namespaces.
Key Files
File Purpose
cluster.yaml
CNPG Cluster CR with managed roles (spec.managed.roles )
databases.yaml
Database CRDs for each application database
role-secrets.yaml
Per-role password secrets (secret-generator + replicator)
pooler.yaml
PgBouncer Pooler for connection pooling
superuser-secret.yaml
Auto-generated superuser password (stays in database ns only)
prometheus-rules.yaml
CNPG-specific PrometheusRules for alerting
kustomization.yaml
Kustomize aggregation of all database resources
Cluster Configuration
kubernetes/platform/config/database/cluster.yaml
apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: platform spec: description: "Shared platform PostgreSQL cluster" imageName: ghcr.io/cloudnative-pg/postgresql:17.2 instances: ${default_replica_count} primaryUpdateStrategy: unsupervised
enableSuperuserAccess: true superuserSecret: name: cnpg-platform-superuser
Declarative role management - CNPG creates/updates these PostgreSQL roles
managed: roles: - name: authelia ensure: present login: true passwordSecret: name: authelia-role-password # ... one entry per application
postgresql: parameters: shared_buffers: "256MB" max_connections: "200" effective_cache_size: "512MB" log_statement: "ddl" log_min_duration_statement: "1000" pg_hba: - host all all 10.0.0.0/8 scram-sha-256 - host all all 172.16.0.0/12 scram-sha-256
storage: storageClass: fast size: ${database_volume_size}
monitoring: enablePodMonitor: true
affinity: enablePodAntiAffinity: true topologyKey: kubernetes.io/hostname
Database CRDs
Each database is declared as a separate Database CR. CNPG creates it if missing or adopts it if it already exists (with databaseReclaimPolicy: retain ):
kubernetes/platform/config/database/databases.yaml
apiVersion: postgresql.cnpg.io/v1 kind: Database metadata: name: authelia-database namespace: database spec: name: authelia # PostgreSQL database name owner: authelia # Must match a managed role name cluster: name: platform databaseReclaimPolicy: retain
Note: Some apps (sonarr, radarr, prowlarr) use multiple databases (e.g. sonarr-main , sonarr-log ) but share a single role. Each database gets its own Database CR with the same owner .
Role Password Secrets
Each managed role has a corresponding Secret in the database namespace. The secret uses secret-generator for auto-generation and kubernetes-replicator for cross-namespace replication to the app that needs it:
kubernetes/platform/config/database/role-secrets.yaml
apiVersion: v1 kind: Secret metadata: name: authelia-role-password namespace: database annotations: secret-generator.v1.mittwald.de/autogenerate: password secret-generator.v1.mittwald.de/encoding: hex secret-generator.v1.mittwald.de/length: "32" replicator.v1.mittwald.de/replication-allowed: "true" replicator.v1.mittwald.de/replication-allowed-namespaces: "authelia" type: kubernetes.io/basic-auth stringData: username: authelia
PgBouncer Pooler
Applications connect through PgBouncer rather than directly to PostgreSQL:
kubernetes/platform/config/database/pooler.yaml
apiVersion: postgresql.cnpg.io/v1 kind: Pooler metadata: name: platform-pooler-rw spec: cluster: name: platform instances: ${default_replica_count} type: rw pgbouncer: poolMode: transaction parameters: max_client_conn: "1000" default_pool_size: "25" monitoring: enablePodMonitor: true
Connection string for apps using the shared cluster:
postgresql://<username>:<password>@platform-pooler-rw.database.svc:5432/<dbname>
Workflow: Add a Database for a New App (Shared Cluster)
Step 1: Add Managed Role to Cluster
Add a new role entry to spec.managed.roles in kubernetes/platform/config/database/cluster.yaml :
managed: roles: # ... existing roles - name: <app> ensure: present login: true passwordSecret: name: <app>-role-password
Step 2: Create Role Password Secret
Add a new Secret to kubernetes/platform/config/database/role-secrets.yaml :
apiVersion: v1 kind: Secret metadata: name: <app>-role-password namespace: database annotations: secret-generator.v1.mittwald.de/autogenerate: password secret-generator.v1.mittwald.de/encoding: hex secret-generator.v1.mittwald.de/length: "32" replicator.v1.mittwald.de/replication-allowed: "true" replicator.v1.mittwald.de/replication-allowed-namespaces: "<app-namespace>" type: kubernetes.io/basic-auth stringData: username: <app>
Step 3: Create Database CRD
Add a new Database CR to kubernetes/platform/config/database/databases.yaml :
apiVersion: postgresql.cnpg.io/v1 kind: Database metadata: name: <app>-database namespace: database spec: name: <app> owner: <app> cluster: name: platform databaseReclaimPolicy: retain
Step 4: Create App-Namespace Credential Replica
Create a replica Secret in the app's config directory that pulls the password from the database namespace:
kubernetes/clusters/<cluster>/config/<app>/<app>-db-credentials.yaml
apiVersion: v1 kind: Secret metadata: name: <app>-db-credentials namespace: <app-namespace> annotations: replicator.v1.mittwald.de/replicate-from: database/<app>-role-password type: kubernetes.io/basic-auth data: { }
Step 5: Add Network Policy Access
The app's namespace must have the postgres access label. Add it in kubernetes/platform/namespaces.yaml :
- name: <app-namespace> labels: pod-security.kubernetes.io/enforce: baseline network-policy.homelab/profile: standard # or appropriate profile access.network-policy.homelab/postgres: "true" # Required for DB access
Step 6: Configure App to Use Database
The app should connect to the Pooler service, not the Cluster directly:
Setting Value
Host platform-pooler-rw.database.svc
Port 5432
Database <app> (created by CNPG Database CRD)
Username From <app>-db-credentials secret (username key)
Password From <app>-db-credentials secret (password key)
Step 7: Register in Kustomization
Add the new credential replica file to the app's kustomization.yaml and ensure the parent config references the directory.
Workflow: Create a Dedicated CNPG Cluster
Use this when an app needs custom PostgreSQL extensions or isolation from the shared cluster.
Step 1: Define the Cluster
kubernetes/clusters/<cluster>/config/<app>/<app>-cluster.yaml
yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/postgresql.cnpg.io/cluster_v1.json
apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: <app>-database namespace: database spec: inheritedMetadata: annotations: replicator.v1.mittwald.de/replication-allowed: "true" replicator.v1.mittwald.de/replication-allowed-namespaces: "<app-namespace>" description: "<App> PostgreSQL with <extension> support" imageName: <custom-image> # e.g., ghcr.io/tensorchord/cloudnative-vectorchord:17.7-1.0.0 instances: ${default_replica_count} primaryUpdateStrategy: unsupervised
postgresql: shared_preload_libraries: - "<extension>.so" # e.g., "vchord.so" parameters: shared_buffers: "128MB" max_connections: "100"
bootstrap: initdb: database: <app> owner: <app> postInitApplicationSQL: - CREATE EXTENSION IF NOT EXISTS <extension> CASCADE;
storage: storageClass: fast size: 10Gi
resources: requests: cpu: 50m memory: 256Mi limits: memory: 1Gi
monitoring: enablePodMonitor: true
affinity: enablePodAntiAffinity: true topologyKey: kubernetes.io/hostname
Real example: kubernetes/clusters/live/config/immich/immich-cluster.yaml
Key Differences from Shared Cluster
Feature Shared Cluster Dedicated Cluster
Location kubernetes/platform/config/database/
kubernetes/clusters/<cluster>/config/<app>/
Namespace database (deployed by platform) database (deployed by cluster config)
Image Standard PostgreSQL Custom image with extensions
Role management spec.managed.roles
- Database CRDs bootstrap.initdb creates DB and owner
Credential source <app>-role-password (secret-generator) <app>-database-app (CNPG auto-generated)
inheritedMetadata
Not needed (role secrets have explicit replication) Required for app secret replication
Superuser Confined to database namespace CNPG auto-generates (<cluster-name>-superuser )
Step 2: Replicate App Credentials to Consumer Namespace
CNPG auto-generates an <cluster-name>-app secret in the database namespace. The inheritedMetadata annotations allow replication to the app namespace:
kubernetes/clusters/<cluster>/config/<app>/database-secret-replication.yaml
apiVersion: v1 kind: Secret metadata: name: cnpg-<app>-database-app namespace: <app-namespace> annotations: replicator.v1.mittwald.de/replicate-from: database/<app>-database-app data: { }
Real example: kubernetes/clusters/live/config/immich/database-secret-replication.yaml
Step 3: Network Policy and Kustomization
Same as shared cluster workflow -- add access.network-policy.homelab/postgres: "true" label and register files in kustomization.yaml .
Credential Chain Summary
Shared Cluster Credential Flow
database namespace app namespace ┌──────────────────────┐ ┌──────────────────────┐ │ <app>-role-password │ kubernetes- │ <app>-db-credentials │ │ (secret-generator) │──replicator──────► │ (replica) │ │ username: <app> │ │ username: <app> │ │ password: <random> │ │ password: <random> │ └──────────────────────┘ └──────────────────────┘ │ ▼ CNPG Cluster (managed role) uses passwordSecret to set PostgreSQL role password │ ▼ Database CRD creates DB owned by role
Key security property: The superuser secret (cnpg-platform-superuser ) never leaves the database namespace. App namespaces only receive their own role password.
Dedicated Cluster Credential Flow
CNPG auto-generates kubernetes-replicator │ │ ▼ ▼ <app>-database-app ──────────► cnpg-<app>-database-app (database ns) (app ns)
The dedicated cluster's inheritedMetadata annotations make the auto-generated secret replicable. The replication-allowed-namespaces controls which namespaces can pull it.
Monitoring
PodMonitor
Both the Cluster and Pooler have enablePodMonitor: true , which creates PodMonitor resources that Prometheus discovers automatically. No additional ServiceMonitor configuration is needed.
PrometheusRules
CNPG-specific alerts are defined in kubernetes/platform/config/database/prometheus-rules.yaml :
Alert Expression Severity Description
CNPGClusterNotHealthy
cnpg_pg_replication_streaming == 0
critical Replication broken
CNPGClusterHighConnections
Connection usage > 80% of max_connections
warning Near connection limit
CNPGInstanceNotReady
Replica WAL receiver down critical Replica cannot follow primary
Key Metrics
Metric Description
cnpg_pg_replication_streaming
Whether streaming replication is active
cnpg_pg_stat_activity_count
Current active connections
cnpg_pg_settings_setting{name="max_connections"}
Configured max connections
cnpg_pg_replication_in_recovery
Whether instance is a replica
cnpg_pg_replication_is_wal_receiver_up
Whether WAL receiver is connected
Configuration Reference
Cluster Spec Fields
Field Purpose Example
instances
Number of PostgreSQL instances (primary + replicas) ${default_replica_count}
imageName
PostgreSQL container image ghcr.io/cloudnative-pg/postgresql:17.2
primaryUpdateStrategy
How to handle primary upgrades unsupervised (automatic failover)
enableSuperuserAccess
Whether to allow superuser connections true for shared, not set for dedicated
superuserSecret
Reference to superuser credentials cnpg-platform-superuser
managed.roles
Declarative PostgreSQL role definitions See Managed Roles section
storage.storageClass
Kubernetes StorageClass for PVCs fast
storage.size
Volume size per instance ${database_volume_size} or 10Gi
monitoring.enablePodMonitor
Auto-create PodMonitor for Prometheus true
affinity.enablePodAntiAffinity
Spread instances across nodes true
postgresql.pg_hba
Client authentication rules Allow pod CIDR with SCRAM-SHA-256
inheritedMetadata
Annotations/labels applied to generated secrets Replication annotations
Managed Role Fields
Field Purpose Example
name
PostgreSQL role name authelia
ensure
Whether role should exist present
login
Whether role can log in true
passwordSecret.name
Secret containing the role password authelia-role-password
Database CRD Fields
Field Purpose Example
spec.name
PostgreSQL database name authelia
spec.owner
Database owner (must be a managed role) authelia
spec.cluster.name
Target CNPG Cluster platform
spec.databaseReclaimPolicy
What happens when CRD is deleted retain (keeps database)
Pooler Spec Fields
Field Purpose Example
cluster.name
Target CNPG Cluster platform
instances
Number of PgBouncer pods ${default_replica_count}
type
Access mode (rw or ro ) rw
pgbouncer.poolMode
Connection pooling mode transaction
pgbouncer.parameters.max_client_conn
Max client connections "1000"
pgbouncer.parameters.default_pool_size
Default server connections per pool "25"
Bootstrap initdb Fields (Dedicated Clusters Only)
Field Purpose Example
database
Database name to create immich
owner
Database owner role immich
postInitApplicationSQL
SQL to run after database creation CREATE EXTENSION IF NOT EXISTS ...
Debugging
Cluster Health
Check cluster status
KUBECONFIG=~/.kube/<cluster>.yaml kubectl get clusters.postgresql.cnpg.io -n database
Detailed cluster info
KUBECONFIG=~/.kube/<cluster>.yaml kubectl describe cluster platform -n database
Check pod status
KUBECONFIG=~/.kube/<cluster>.yaml kubectl get pods -n database -l cnpg.io/cluster=platform
Check pooler status
KUBECONFIG=~/.kube/<cluster>.yaml kubectl get poolers.postgresql.cnpg.io -n database
Check managed databases
KUBECONFIG=~/.kube/<cluster>.yaml kubectl get databases.postgresql.cnpg.io -n database
Common Issues
Symptom Cause Fix
Pods Pending No PVs available Check StorageClass fast exists, Longhorn is healthy
CrashLoopBackOff OOM or bad config Check kubectl logs , increase memory limits
Replication lag Slow disk or network Check cnpg_pg_replication_streaming metric
App can't connect Network policy missing Add access.network-policy.homelab/postgres: "true" label
App can't connect Secret not replicated Check replication annotations on source secret
Secret empty after replication Source namespace wrong Verify replicate-from points to correct <ns>/<name>
Extension not found Wrong image Verify imageName includes the extension
Database not created Database CRD missing Add Database CR to databases.yaml
Role not created Missing from managed.roles Add role entry to cluster.yaml
Role password mismatch Secret not regenerated Delete the role-password secret, secret-generator will recreate
Checking Connectivity
Verify network policy allows traffic
hubble observe --from-namespace <app-ns> --to-namespace database --since 5m
Test connection from a debug pod
KUBECONFIG=~/.kube/<cluster>.yaml kubectl run -n <app-ns> pg-test --rm -it
--image=postgres:17 -- psql "postgresql://user:pass@platform-pooler-rw.database.svc:5432/dbname"
CNPG Plugin (Optional)
The kubectl cnpg plugin provides additional cluster management commands:
kubectl cnpg status platform -n database kubectl cnpg promote platform-2 -n database # Manual failover
Cross-References
Document Relevance
kubernetes/platform/config/CLAUDE.md Config subsystem and CRD dependency patterns
secrets skill secret-generator, ExternalSecret, and replication patterns
deploy-app skill End-to-end deployment including database setup
sre skill Debugging methodology for database incidents