CNPG Recipe 27 - Running OpenBao on Kubernetes with a CloudNativePG PostgreSQL backend

Table of Contents
A walkthrough of running OpenBao on Kubernetes with
CloudNativePG as its PostgreSQL storage
backend. Every layer of this stack is open source, with no vendor lock-in:
Kubernetes and CloudNativePG are both CNCF
projects, authenticated entirely over TLS client certificates via the 1.30
DatabaseRole CRD, with no passwords anywhere in the stack. Co-authored
with Rob Kenefeck from
ControlPlane.
Managing infrastructure secrets on Kubernetes needs a backend that is
self-healing and free of vendor lock-in, and that is exactly what
OpenBao (the Linux Foundation’s open-source fork of
HashiCorp Vault) and CloudNativePG give you:
an entirely open-source stack built on two CNCF projects,
Kubernetes, long since graduated, and
CloudNativePG, a CNCF Sandbox project currently
under evaluation for Incubation
by the CNCF Technical Oversight Committee. OpenBao’s postgresql storage
backend turns any PostgreSQL cluster into its encrypted key-value store,
and CloudNativePG turns that cluster into a self-healing, synchronously
replicated, certificate-authenticated Postgres instance with no cloud
database dependency underneath it.
This recipe deploys a three-instance CNPG cluster as OpenBao’s storage
backend and removes every password from the connection: the schema-owning
role and the application role OpenBao itself uses both authenticate with a
DatabaseRole-issued TLS client certificate, enforced by explicit pg_hba
rules rather than by the absence of a password. pg_hba.conf is
PostgreSQL’s client-authentication file, the thing that actually decides,
per connection, whether a role needs a certificate, a password, or nothing
at all.
Setting up a local test environment with cnpg-playground #
Nothing about this recipe is specific to any one Kubernetes distribution:
any conformant cluster with enough worker capacity will do. To follow along
locally, though, the official
cnpg-playground
repository is the fastest path to one, since it is pre-configured with the
CloudNativePG operator already. It is designed primarily around CNPG’s own
demos, so it is worth knowing what it actually gives you: a single Kind
cluster with six nodes, a control plane node, one node labelled for
infrastructure workloads, one labelled for application workloads, and three
carrying a node-role.kubernetes.io/postgres taint. That taint is exactly
what our Cluster’s tolerations in Step 1 target, and it is also what
leaves OpenBao itself with only the two general-purpose nodes to schedule
onto, which matters once pod anti-affinity enters the picture in Step 3.
setup.sh provisions one Kind cluster per argument it is given, normally
used to model separate regions; passing it a single, arbitrary label gives
you one local cluster and skips the two-region disaster recovery demo
entirely.
Prerequisites: Docker,
Kind, Helm and kubectl,
plus cnpg-playground’s own
prerequisites:
the cnpg plugin for kubectl, and, since ./demo/setup.sh also installs
cert-manager, cmctl.
# Clone the CNPG Playground repository
git clone https://github.com/cloudnative-pg/cnpg-playground.git
cd cnpg-playground
# 1. Provision a single local cluster labelled "openbao"
./scripts/setup.sh openbao
# 2. Deploy CloudNativePG, cert-manager, the Barman Cloud plugin and a
# ClusterImageCatalog only, skipping the demo databases
REQUIREMENTS_ONLY=true ./demo/setup.sh
Architecture blueprint #

- Storage engine: OpenBao’s native
postgresqlstorage backend, withha_enabled = "true"for its HA lock table. - Database cluster: a 3-instance CNPG cluster with
quorum-based synchronous replication
(
method: any,number: 1, the defaultdataDurability: required) for zero-data-loss failover. - Workload isolation: node selectors, tolerations and required zonal pod anti-affinity keep PostgreSQL on dedicated nodes across separate failure domains, following CNPG’s scheduling guidance.
- Authentication: passwordless mTLS via the
DatabaseRoleCRD’sclientCertificateblock, for both the schema owner and the application role, enforced by explicitpg_hbarules.
Step 1: deploy the CNPG cluster, roles and database #
The Cluster below points imageCatalogRef at the
postgresql-minimal-trixie ClusterImageCatalog that
REQUIREMENTS_ONLY=true ./demo/setup.sh already deployed in the previous
step, rather than pinning an image tag directly: CNPG resolves it to the
latest minimal PostgreSQL 18 image in that catalog, so a kubectl apply
against the same manifest keeps picking up new patch releases as the
catalog is updated, no Cluster edit required. It also declares
synchronous replication, workload isolation, and the two pg_hba rules
that force certificate authentication for both roles OpenBao will use. Two
DatabaseRole objects follow: role-openbao, the schema owner used once
to run DDL, and role-openbao-rw, the restricted role OpenBao itself
connects as at runtime. Both get a clientCertificate, because a one-shot
DDL job is no more entitled to a password lying around than the
application is.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: openbao-db
namespace: openbao
spec:
instances: 3
# Tracks the latest minimal PostgreSQL 18 image via the ClusterImageCatalog
# the playground's REQUIREMENTS_ONLY step already deploys.
# See https://cloudnative-pg.io/docs/current/image_catalog
imageCatalogRef:
apiGroup: postgresql.cnpg.io
kind: ClusterImageCatalog
name: postgresql-minimal-trixie
major: 18
# See https://cloudnative-pg.io/docs/current/scheduling
affinity:
nodeSelector:
node-role.kubernetes.io/postgres: ""
tolerations:
- key: node-role.kubernetes.io/postgres
operator: Exists
effect: NoSchedule
enablePodAntiAffinity: true
topologyKey: topology.kubernetes.io/zone
podAntiAffinityType: required
postgresql:
# Synchronous replication: dataDurability defaults to "required", giving
# RPO=0 at the cost of pausing writes if no standby is available.
# See https://cloudnative-pg.io/docs/current/replication
synchronous:
method: any
number: 1
# The operator does not add cert rules for DatabaseRole client
# certificates automatically: without these, "openbao" and "openbao-rw"
# would fall through to the default scram-sha-256 rule, and since
# neither role has a passwordSecret, every connection would simply fail.
pg_hba:
- hostssl openbao openbao all cert
- hostssl openbao openbao-rw all cert
- hostnossl openbao openbao all reject
- hostnossl openbao openbao-rw all reject
# See https://cloudnative-pg.io/docs/current/postgresql_conf
parameters:
max_connections: '100'
log_checkpoints: 'on'
log_lock_waits: 'on'
hot_standby_feedback: 'on'
shared_memory_type: 'sysv'
dynamic_shared_memory_type: 'sysv'
storage:
size: 10Gi
---
apiVersion: postgresql.cnpg.io/v1
kind: DatabaseRole
metadata:
name: role-openbao
namespace: openbao
spec:
cluster:
name: openbao-db
name: openbao
login: true
clientCertificate:
enabled: true
databaseRoleReclaimPolicy: retain
---
apiVersion: postgresql.cnpg.io/v1
kind: DatabaseRole
metadata:
name: role-openbao-rw
namespace: openbao
spec:
cluster:
name: openbao-db
name: openbao-rw
login: true
clientCertificate:
enabled: true
databaseRoleReclaimPolicy: retain
---
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: openbao-db
namespace: openbao
spec:
name: openbao
owner: openbao
cluster:
name: openbao-db
Apply these resources:
kubectl create namespace openbao
kubectl apply -f cnpg-stack.yaml
Watch for all three instance pods to come up, which takes a couple of minutes on a fresh cluster:
kubectl get pods -w -n openbao
Once all three are Running and Ready, confirm the cluster itself has
reached a healthy state:
kubectl cnpg -n openbao status openbao-db
Cluster Summary
Name openbao/openbao-db
System ID: 7674399793927340061
PostgreSQL Image: ghcr.io/cloudnative-pg/postgresql:18.6-202608131513-minimal-trixie@sha256:e488b1434919f455f2ee4e18a181ce9b33f34cdd8dfb821126855486bce6ad34
Primary instance: openbao-db-1
Primary promotion time: 2026-08-15 23:10:49 +0000 UTC (3m15s)
Status: Cluster in healthy state
Instances: 3
Ready instances: 3
Size: 135M
Current Write LSN: 0/6000060 (Timeline: 1 - WAL File: 000000010000000000000006)
Continuous Backup not configured
Streaming Replication status
Replication Slots Enabled
Name Sent LSN Write LSN Flush LSN Replay LSN Write Lag Flush Lag Replay Lag State Sync State Sync Priority Replication Slot
---- -------- --------- --------- ---------- --------- --------- ---------- ----- ---------- ------------- ----------------
openbao-db-2 0/6000060 0/6000060 0/6000060 0/6000060 00:00:00 00:00:00 00:00:00 streaming quorum 1 active
openbao-db-3 0/6000060 0/6000060 0/6000060 0/6000060 00:00:00 00:00:00 00:00:00 streaming quorum 1 active
Instances status
Name Current LSN Replication role Status QoS Manager Version Node
---- ----------- ---------------- ------ --- --------------- ----
openbao-db-1 0/6000060 Primary OK BestEffort 1.30.0 k8s-openbao-worker3
openbao-db-2 0/6000060 Standby (sync) OK BestEffort 1.30.0 k8s-openbao-worker4
openbao-db-3 0/6000060 Standby (sync) OK BestEffort 1.30.0 k8s-openbao-worker5
Note the PostgreSQL Image line: a SHA-pinned, dated minimal build resolved
straight out of the postgresql-minimal-trixie catalog, not a floating tag
we wrote by hand.
Both standbys show up as Standby (sync) with a Sync State of quorum at
the same time, which is exactly the dynamic behaviour method: any is meant
to give: with number: 1, either standby satisfies durability, and CNPG
does not pin a fixed “the” synchronous standby.
Once reconciled, the operator has created two client certificate secrets,
role-openbao-client-cert and role-openbao-rw-client-cert, following its
<databaserole-name>-client-cert naming convention. openbao, as the
database owner, already has CREATE on the public schema by default
(PostgreSQL grants that to the owner even though it revoked it from PUBLIC
in v15), so no extra schema grant is needed before the DDL step.
Every manifest that mounts one of these secrets sets defaultMode: 0640 on
the volume. Kubernetes mounts Secret volumes at 0644 by default, which
libpq refuses outright: it rejects a private key file that is
group-or-world-readable, whether owned by root (0640 or less) or by the
connecting user (0600 or less). Since the mounted files stay root-owned
and only their group matches the pod’s fsGroup, 0640 is the setting
that satisfies libpq here, and it applies to every pod in this recipe
that reads a client certificate, the schema-init Job and the OpenBao
pods alike.
Step 2: initialise the schema and grant table privileges #
DatabaseRole does not yet manage table-level grants: the permissions
stanza that would let a Database object express GRANT/REVOKE
declaratively is still an open proposal
(#10826),
as I covered when DatabaseRole first shipped in
Recipe 25.
Until that lands, a one-time Job running the DDL as the schema owner is the
correct way to create OpenBao’s tables and grant the restricted DML the
openbao-rw role actually needs.
OpenBao’s postgresql storage backend expects two tables when
ha_enabled = "true": openbao_kv_store, with a parent_path, path,
key and value column and a primary key on (path, key), and
openbao_ha_locks, holding its HA lock records. Getting the key column or
the primary key wrong here is an easy mistake, since OpenBao would otherwise
silently create the table itself on first connection using its own DDL, and
that path only works if the connecting role already has CREATE, which
openbao-rw deliberately does not. Pre-creating both tables under the owner
role and setting skip_create_table on the OpenBao side (Step 3) keeps that
DDL entirely off the restricted runtime role.
The same job also closes a gap PostgreSQL leaves open by default: every
database grants CONNECT to PUBLIC, and the public schema grants
USAGE to PUBLIC too, so any role that can log into the cluster at all
can connect to openbao and see what is in its public schema unless told
otherwise. Making REVOKE CONNECT ... FROM PUBLIC the default posture
across every database CloudNativePG manages is on the roadmap
(#10831),
but it is not there yet, so the schema-init job revokes it explicitly here
and grants back only what openbao-rw actually needs:
apiVersion: batch/v1
kind: Job
metadata:
name: openbao-schema-init
namespace: openbao
spec:
ttlSecondsAfterFinished: 300 # Clean up job 5 minutes post-completion
template:
metadata:
name: openbao-schema-init
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 26
fsGroup: 26
seccompProfile:
type: RuntimeDefault
containers:
- name: psql-init
image: ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "500m"
memory: "128Mi"
command:
- psql
- "postgres://openbao@openbao-db-rw:5432/openbao?sslmode=verify-full&sslcert=/etc/certs/tls.crt&sslkey=/etc/certs/tls.key&sslrootcert=/etc/ca/ca.crt"
- -c
- |
CREATE TABLE IF NOT EXISTS openbao_kv_store (
parent_path TEXT NOT NULL,
path TEXT NOT NULL,
key TEXT NOT NULL,
value BYTEA,
CONSTRAINT openbao_kv_store_pkey PRIMARY KEY (path, key)
);
CREATE INDEX IF NOT EXISTS openbao_kv_store_idx
ON openbao_kv_store (parent_path);
CREATE TABLE IF NOT EXISTS openbao_ha_locks (
ha_key TEXT NOT NULL,
ha_identity TEXT NOT NULL,
ha_value TEXT,
valid_until TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT openbao_ha_locks_pkey PRIMARY KEY (ha_key)
);
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE openbao_kv_store, openbao_ha_locks
TO "openbao-rw";
REVOKE CONNECT ON DATABASE openbao FROM PUBLIC;
GRANT CONNECT ON DATABASE openbao TO "openbao-rw";
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO "openbao-rw";
volumeMounts:
- name: certs
mountPath: /etc/certs
readOnly: true
- name: ca
mountPath: /etc/ca
readOnly: true
volumes:
- name: certs
secret:
secretName: role-openbao-client-cert
defaultMode: 0640
- name: ca
secret:
secretName: openbao-db-ca
defaultMode: 0640
Apply the job:
kubectl apply -f schema-init-job.yaml
Wait for its pod to finish ContainerCreating and complete before reading
its logs, otherwise kubectl logs fails outright rather than waiting:
kubectl wait --for=condition=complete -n openbao job/openbao-schema-init --timeout=60s
kubectl logs -n openbao job/openbao-schema-init
CREATE TABLE
CREATE INDEX
CREATE TABLE
GRANT
REVOKE
GRANT
REVOKE
GRANT
Eight statements in, eight confirmations out: both tables, the DML grant,
and the three REVOKE/GRANT pairs that lock the database and the
public schema down to openbao-rw.
Step 3: configure and deploy OpenBao via Helm #
Configure the official OpenBao Helm chart.
Mount the role-openbao-rw-client-cert secret into OpenBao and point the
postgresql storage stanza at it with sslmode=verify-full. A few details
that are easy to miss from the OpenBao side: skip_create_table must be set
explicitly, since openbao-rw has no CREATE privilege and would otherwise
fail on first connection when OpenBao tries to create the tables itself, and
server.dataStorage needs disabling, since it defaults to a 10Gi PVC per pod
that would otherwise sit there unused: the whole point of this stack is that
OpenBao carries no local state at all. Mounting the certificate and CA
secrets also needs the right chart field: server.extraVolumes looks like
the obvious choice, but it uses its own simplified type/name/path
schema rather than a raw Kubernetes volume, and there is no matching
extraVolumeMounts field for the server StatefulSet at all. server.volumes
and server.volumeMounts are the fields that pass straight through to the
Pod spec, and are what the manifest below actually uses.
global:
enabled: true
server:
# No local persistence: all state lives in CNPG.
dataStorage:
enabled: false
ha:
enabled: true
replicas: 3
config: |
ui = true
listener "tcp" {
tls_disable = 1
address = "[::]:8200"
cluster_address = "[::]:8201"
}
storage "postgresql" {
connection_url = "postgres://openbao-rw@openbao-db-rw:5432/openbao?sslmode=verify-full&sslcert=/etc/openbao/certs/tls.crt&sslkey=/etc/openbao/certs/tls.key&sslrootcert=/etc/openbao/ca/ca.crt"
table = "openbao_kv_store"
ha_table = "openbao_ha_locks"
ha_enabled = "true"
skip_create_table = "true"
}
# cnpg-playground only: with the Postgres nodes tainted and off limits,
# only two general-purpose nodes are left, one short of what three
# required-anti-affinity replicas need. Tolerating the control-plane
# taint gives OpenBao a third node to land on. Drop this in a cluster
# with three or more untainted worker nodes, and never carry it into
# production: workloads should not run on the control plane there.
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
# Mount the openbao-rw DatabaseRole's client certificate and the
# cluster's client CA. "volumes"/"volumeMounts" are passed through to the
# Pod spec as-is; the chart's own "extraVolumes" field uses a different,
# simplified schema (type/name/path) that does not accept a raw Secret
# volume, and there is no "extraVolumeMounts" field for the server
# StatefulSet at all.
volumes:
- name: cnpg-client-cert
secret:
secretName: role-openbao-rw-client-cert
defaultMode: 0640
- name: cnpg-client-ca
secret:
secretName: openbao-db-ca
defaultMode: 0640
volumeMounts:
- name: cnpg-client-cert
mountPath: /etc/openbao/certs
readOnly: true
- name: cnpg-client-ca
mountPath: /etc/openbao/ca
readOnly: true
Install OpenBao following the official OpenBao Kubernetes documentation:
helm repo add openbao https://openbao.github.io/openbao-helm
helm repo update
helm install openbao openbao/openbao \
--namespace openbao \
-f openbao-values.yaml
Pod anti-affinity for the OpenBao replicas is not something this recipe has
to configure: the chart’s server.affinity default already renders a
requiredDuringSchedulingIgnoredDuringExecution rule keyed on
kubernetes.io/hostname, so the three server pods refuse to land on the
same node. In cnpg-playground specifically, that is worth double
checking rather than assuming: with the Postgres nodes tainted and off
limits, only the infra- and app-labelled nodes are left for general
workloads, one short of what three required-anti-affinity replicas need.
The manifest above adds a server.tolerations entry for the
node-role.kubernetes.io/control-plane taint so OpenBao can use that node
as its third, which is a reasonable thing to do on a single-developer Kind
cluster and not something to carry into a real cluster, where the control
plane should stay clear of ordinary workloads. On a cluster with three or
more untainted worker nodes, the toleration is unnecessary and the chart’s
default anti-affinity just works on its own.
Step 4: verification and initialisation #
Watch the OpenBao pods come up:
kubectl get pods -w -n openbao
NAME READY STATUS RESTARTS AGE
openbao-0 1/1 Running 0 3m11s
openbao-1 0/1 Running 0 51s
openbao-agent-injector-84c76c686f-7qnq6 1/1 Running 0 3m11s
openbao-db-1 1/1 Running 0 8m43s
openbao-db-2 1/1 Running 0 6m29s
openbao-db-3 1/1 Running 0 5m48s
openbao-schema-init-x9clq 0/1 Completed 0 4m7s
Only openbao-0 and openbao-1 exist so far, and openbao-1 shows 0/1:
the StatefulSet’s default OrderedReady policy will not even create
openbao-2 until openbao-1 reports Ready, and readiness here is the
unseal status, not just the process being up. That is exactly why the
initialisation and unsealing below has to happen pod by pod, in order:
openbao-0 first, then openbao-1, then openbao-2, each one only
created once its predecessor is unsealed.
Initialise and unseal OpenBao #
Initialise the cluster on openbao-0 and unseal all three pods:
kubectl exec -it -n openbao openbao-0 -- bao operator init
Store the generated unseal keys and root token securely.
Each pod’s Shamir state is independent and in memory: unsealing openbao-0
does nothing for openbao-1 or openbao-2, so the same three keys have to
be submitted again, against each pod by name, one pod at a time:
kubectl exec -it -n openbao openbao-0 -- bao operator unseal <UNSEAL_KEY_1>
kubectl exec -it -n openbao openbao-0 -- bao operator unseal <UNSEAL_KEY_2>
kubectl exec -it -n openbao openbao-0 -- bao operator unseal <UNSEAL_KEY_3>
kubectl exec -it -n openbao openbao-1 -- bao operator unseal <UNSEAL_KEY_1>
kubectl exec -it -n openbao openbao-1 -- bao operator unseal <UNSEAL_KEY_2>
kubectl exec -it -n openbao openbao-1 -- bao operator unseal <UNSEAL_KEY_3>
kubectl exec -it -n openbao openbao-2 -- bao operator unseal <UNSEAL_KEY_1>
kubectl exec -it -n openbao openbao-2 -- bao operator unseal <UNSEAL_KEY_2>
kubectl exec -it -n openbao openbao-2 -- bao operator unseal <UNSEAL_KEY_3>
Until a pod’s three keys go in, kubectl describe pod on it shows
Unseal Progress: 0/3 and a stream of Warning Unhealthy readiness-probe
events. Neither is a problem: it is the probe correctly reporting that the
pod is still sealed, and it clears as soon as that pod gets its keys.
The third key flips Sealed to false:
Key Value
--- -----
Seal Type shamir
Initialized true
Sealed false
Total Shares 5
Threshold 3
Version 2.6.1
Commit Date 2026-07-22T14:22:33Z
Storage Type postgresql
Cluster Name vault-cluster-97b6aa67
Cluster ID 634fcfcf-09f2-b719-f9bf-1f684cfbe88a
HA Enabled true
HA Cluster n/a
HA Mode standby
Storage Type: postgresql here is the whole point of this recipe, and it
is not just reporting the config back: OpenBao writes its own bootstrap
state, the keyring, the root key material, its seal configuration, before
you ever create an application secret. Query the cluster directly and it
is already there:
kubectl cnpg psql -n openbao openbao-db -- openbao
openbao=# SELECT key FROM openbao_kv_store;
key
---------------------------------------------------------------------
keyring
root-key
shamir-kek
seal-config
barrier-unseal-keys
info
jwtkey
...
(31 rows)
Every one of those rows lives in the openbao_kv_store table this
recipe’s schema-init Job created, written by the openbao-rw role with
nothing but a client certificate.
Read/write test #
Confirm OpenBao can write an encrypted payload through to CNPG using the
restricted openbao-rw role:
# Login
kubectl exec -it -n openbao openbao-0 -- bao login <ROOT_TOKEN>
# Enable KV v2 and write a secret
kubectl exec -it -n openbao openbao-0 -- bao secrets enable -path=secret kv-v2
kubectl exec -it -n openbao openbao-0 -- bao kv put secret/test-app username="admin" password="supersecretpassword"
# Read it back
kubectl exec -it -n openbao openbao-0 -- bao kv get secret/test-app
Run the same SELECT key FROM openbao_kv_store; query again and a new row
for secret/test-app shows up alongside the bootstrap keys, its value
column holding the payload as an encrypted BYTEA blob, never plaintext,
even to someone with direct
PostgreSQL access to the table.
Now that all three pods are unsealed, -o wide on the whole namespace shows
the full picture: the three CNPG instances on their three dedicated, tainted
Postgres nodes, and the three OpenBao replicas spread across the
control-plane node and the two general-purpose ones, required anti-affinity
satisfied without a single pod colocated with another:
kubectl get pods -n openbao -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
openbao-0 1/1 Running 0 29m 10.244.3.4 k8s-openbao-worker2 <none> <none>
openbao-1 1/1 Running 0 26m 10.244.2.7 k8s-openbao-worker <none> <none>
openbao-2 1/1 Running 0 74s 10.244.0.6 k8s-openbao-control-plane <none> <none>
openbao-agent-injector-84c76c686f-7qnq6 1/1 Running 0 29m 10.244.2.6 k8s-openbao-worker <none> <none>
openbao-db-1 1/1 Running 0 34m 10.244.5.4 k8s-openbao-worker3 <none> <none>
openbao-db-2 1/1 Running 0 32m 10.244.1.4 k8s-openbao-worker4 <none> <none>
openbao-db-3 1/1 Running 0 31m 10.244.6.4 k8s-openbao-worker5 <none> <none>
All seven workloads nicely distributed across the six available nodes on the playground, control plane included, exactly as intended.
Operational notes: certificate renewal #
CNPG’s client certificates carry a 90-day validity period and are renewed
automatically about a week before expiry, the same schedule the operator
already applies to the streaming_replica certificate
(see Certificates).
Renewal replaces the contents of the role-openbao-rw-client-cert secret in
place, no manifest change required. Both figures are inherited unconditionally
from the operator’s own global settings today; a proposal to let
clientCertificate override duration and renewBefore per DatabaseRole,
mirroring cert-manager’s convention, is open in
issue #11312,
useful if a role like role-openbao-rw ever needs a renewal cadence
different from the cluster-wide default.
OpenBao itself does not pick that renewal up on its own. Like Vault before
it, OpenBao’s postgresql storage backend opens its connection pool once at
process startup and never re-reads the certificate files afterwards, so a
renewed certificate only takes effect after a rolling restart of the OpenBao
pods. This is a property of the storage plugin’s own connection lifecycle,
not something CNPG’s certificate reconciliation controls: CNPG’s job ends at
keeping the secret current, and nothing on the CNPG side requires a restart.
Until OpenBao’s storage backend gains a way to reload its connection pool’s
TLS material on signal, budget for a scheduled rolling restart inside the
83-day renewal window, well before the old certificate actually expires.
Beyond this setup: backups and disaster recovery #
This is a single-cluster deployment. High availability inside the Kubernetes cluster is covered by the three OpenBao replicas, CNPG’s synchronous replication and required zonal anti-affinity, but production workloads need more than that:
- Automated PostgreSQL backups: the in-tree
.spec.backup.barmanObjectStorestanza is deprecated as of CNPG 1.26 in favour of the Barman Cloud Plugin: define anObjectStorepointing at AWS S3, Google Cloud Storage or Azure Blob Storage, reference it from theCluster’s.spec.plugins, and back it withBackup/ScheduledBackupresources usingmethod: pluginfor continuous WAL archiving and scheduled base backups, enabling Point-In-Time Recovery. - Disaster recovery: to meet real RTO/RPO targets, span the deployment across more than one Kubernetes cluster. CNPG’s distributed topology lets an asynchronous replica cluster in a second region or cluster promote to primary if the first one is lost entirely, the same capability I discussed for cloud-neutral portability.
Conclusion #
Combining OpenBao with CloudNativePG gives you a fully open-source,
enterprise-grade secrets engine running on native
Kubernetes CRDs, with the DatabaseRole CRD
covering every role in the stack rather than just the application-facing
one. Synchronous replication gives zero-data-loss failover, explicit
pg_hba rules turn the absence of a password into an actual enforced
policy rather than an assumption, and dedicated scheduling rules keep the
database’s failure domains separate from everything else running in the
cluster.
If you have ideas, questions, or run into something this recipe does not cover, reach out to Rob and me directly on LinkedIn, or find us on the CloudNativePG community’s CNCF Slack.
Stay tuned for the upcoming recipes! For the latest updates, consider subscribing to my LinkedIn and Twitter channels.
If you found this article informative, feel free to share it within your network on social media using the provided links below. Your support is immensely appreciated!
This article was drafted and refined with the assistance of Claude (Anthropic). All technical content, corrections and editorial direction are the author’s own.