Database backup and restore
The registry’s only state is its PostgreSQL database. On Kubernetes it runs under the CloudNativePG (CNPG) operator, which uses Barman to ship continuous WAL and scheduled base backups to object storage and restores them by point-in-time recovery (PITR).
Objectives
Section titled “Objectives”- RPO: ≤ 5 minutes, from continuous WAL archiving with CNPG’s default
archive_timeoutof 5 minutes. - RTO: ≤ 15 minutes for an intra-region PITR restore of a cluster under 20 GB; larger datasets scale with object-storage throughput.
- Retention: 30 days of base backups and WAL. Longer retention is a compliance decision, not a technical one.
Where backups are configured
Section titled “Where backups are configured”The CNPG Cluster the chart renders with cnpg.enabled: true
(templates/cnpg-cluster.yaml)
has no backup stanza, and the chart exposes no values for one. A backed-up
database is therefore a Cluster managed outside the chart, which the server
reaches through api.database.existingSecret:
-
Leave
cnpg.enabledatfalse. -
Create the
Clusterand itsScheduledBackup(below) in the release namespace. -
Create a Secret holding the DSN under the key
DATABASE_URL. Theurikey of the<cluster>-appSecret CNPG generates targets the application database and works as is:Terminal window kubectl -n <ns> create secret generic ai-registry-database \--from-literal=DATABASE_URL="$(kubectl -n <ns> get secret ai-registry-postgres-app -o jsonpath='{.data.uri}' | base64 -d)" -
Set
api.database.existingSecret: ai-registry-database.
The application role owns the database, which is all the server’s migrations need.
Configuring backups
Section titled “Configuring backups”Object-storage credentials live in a Secret created out of band (external secrets, sealed secrets, or by hand — never committed):
apiVersion: v1kind: Secretmetadata: name: ai-registry-backup-credstype: OpaquestringData: ACCESS_KEY_ID: "…" SECRET_ACCESS_KEY: "…"The Cluster, with the same shape as the chart’s plus a backup stanza:
apiVersion: postgresql.cnpg.io/v1kind: Clustermetadata: name: ai-registry-postgresspec: instances: 1 imageName: ghcr.io/cloudnative-pg/postgresql:18 bootstrap: initdb: database: ai_registry owner: ai_registry storage: size: 5Gi backup: barmanObjectStore: destinationPath: s3://my-backups/ai-registry endpointURL: https://s3.eu-west-1.amazonaws.com s3Credentials: accessKeyId: name: ai-registry-backup-creds key: ACCESS_KEY_ID secretAccessKey: name: ai-registry-backup-creds key: SECRET_ACCESS_KEY wal: compression: gzip maxParallel: 8 data: compression: gzip immediateCheckpoint: false jobs: 2 retentionPolicy: "30d"A daily base backup:
apiVersion: postgresql.cnpg.io/v1kind: ScheduledBackupmetadata: name: ai-registry-dailyspec: schedule: "0 0 4 * * *" # 04:00 UTC daily (CNPG cron has a seconds field) backupOwnerReference: self cluster: name: ai-registry-postgresThe in-tree barmanObjectStore is deprecated upstream in favour of the
Barman Cloud plugin; follow
the CNPG backup documentation
for the operator version you run.
Verifying a backup
Section titled “Verifying a backup”kubectl cnpg backup list -n <ns> ai-registry-postgreskubectl -n <ns> describe backup <backup-name>CNPG exports Prometheus metrics for backups: alert when
cnpg_collector_last_available_backup_timestamp is more than a day old, and on
any cnpg_collector_last_failed_backup_timestamp more recent than it.
Restore / PITR
Section titled “Restore / PITR”CNPG restores by creating a new cluster bootstrapped from the object store; it never restores into a running cluster.
apiVersion: postgresql.cnpg.io/v1kind: Clustermetadata: name: ai-registry-postgres-restoredspec: instances: 1 imageName: ghcr.io/cloudnative-pg/postgresql:18 bootstrap: recovery: source: origin # Omit recoveryTarget to replay all archived WAL. recoveryTarget: targetTime: "2026-04-20 12:00:00+00" externalClusters: - name: origin barmanObjectStore: destinationPath: s3://my-backups/ai-registry # The backed-up cluster's name: backups live under # <destinationPath>/<serverName>. serverName: ai-registry-postgres s3Credentials: accessKeyId: { name: ai-registry-backup-creds, key: ACCESS_KEY_ID } secretAccessKey: { name: ai-registry-backup-creds, key: SECRET_ACCESS_KEY } storage: size: 5GiIf the restored cluster gets its own backup stanza, it must archive to a
different destinationPath or serverName: CNPG refuses to archive into a
non-empty WAL archive.
Once kubectl cnpg status -n <ns> ai-registry-postgres-restored reports a
healthy cluster:
- Point the
DATABASE_URLSecret at the restored cluster’sai-registry-postgres-restored-appuri(same command as above). - Restart the server so the pool reconnects:
kubectl -n <ns> rollout restart deploy/<fullname>-api. - Run the smoke test against the deployment (test/load/README.md).
Restore drill
Section titled “Restore drill”Run it quarterly — a backup that has never been restored is not a backup:
- Trigger an on-demand backup:
kubectl cnpg backup -n <ns> ai-registry-postgres. - Restore it into a scratch namespace with the manifest above.
- Run the smoke test against a server pointed at the restored cluster.
- Record the backup size, the restore duration and any failure.
A failed drill means disaster recovery is broken: treat it as an incident.