A minimal Kubernetes operator that manages PostgreSQL declaratively through three custom resources:
PostgresInstance— provisions a running server: aSecretwith generated credentials, aStatefulSetbacked by aPersistentVolumeClaim, theServices needed to reach it, and an optional scheduled backupCronJob.PostgresUser— creates a login role inside an instance and publishes a ready-to-mount credentialsSecret.PostgresDatabase— creates a database owned by a role, with adeletionPolicythat decides whether the real database is dropped on deletion.
Built with kubebuilder and controller-runtime. This is a learning project, so the code favours being readable over being feature-complete — see Roadmap for what is intentionally left out.
Applying a single PostgresInstance makes the operator reconcile five child
objects, each owned by the instance (so they are garbage-collected when it is
deleted):
PostgresInstance "demo"
├── Secret demo-superuser generated postgres password (created once)
├── Service demo ClusterIP — stable address for clients
├── Service demo-hl headless — stable DNS identity for the pod
├── StatefulSet demo postgres:<version>, 1 replica
│ └── PVC data-demo-0 survives pod restarts
└── status phase / endpoint / Ready condition
The reconcile loop is idempotent and level-based: it looks at the desired state
(spec) and the real cluster state, makes them match, and records what it
observed back into status.
PostgresUser and PostgresDatabase connect to the instance over the PostgreSQL
protocol (via pgx) to run CREATE ROLE /
CREATE DATABASE. Because a role or database lives inside Postgres — where the
Kubernetes garbage collector cannot see it — each uses a finalizer to clean up
on deletion. They also watch their referenced PostgresInstance, so a
dependent that was waiting reconciles the moment the instance becomes Ready
instead of polling.
When spec.backup is set, the operator maintains a CronJob that runs a small
image (backup/, built from postgres:<version> plus the MinIO client). On each
run it streams pg_dumpall | gzip straight into an S3-compatible bucket:
pg_dumpall | gzip | mc pipe s3/<bucket>/<instance>/<timestamp>.sql.gz
pg_dumpall captures every database and the global roles in one file, so a dump
is a complete, restorable snapshot of the server. The Postgres password comes from
the instance's superuser Secret; the S3 credentials come from the user-supplied
Secret named by spec.backup.s3.credentialsSecretRef — the operator references
both by name and never reads them itself. Clearing spec.backup deletes the
CronJob.
apiVersion: dbaas.nitroline.dev/v1alpha1
kind: PostgresInstance
metadata:
name: demo
spec:
version: "17"
storage:
size: 1Gi
---
apiVersion: dbaas.nitroline.dev/v1alpha1
kind: PostgresUser
metadata:
name: app-user
spec:
instanceRef: demo
name: app_user
---
apiVersion: dbaas.nitroline.dev/v1alpha1
kind: PostgresDatabase
metadata:
name: app-db
spec:
instanceRef: demo
name: app
owner: app_user
deletionPolicy: Retain # keep the data if this resource is deleted$ kubectl get postgresinstances
NAME VERSION PHASE ENDPOINT
demo 17 Running demo.default.svc.cluster.local:5432
$ kubectl get secret app-user-credentials -o jsonpath='{.data.password}' | base64 -dThe instance reports readiness through a standard condition, so it composes with
kubectl wait:
$ kubectl wait postgresinstance/demo --for=condition=Ready --timeout=120sPostgresInstance (dbaas.nitroline.dev/v1alpha1):
| Field | Required | Description |
|---|---|---|
spec.version |
yes | PostgreSQL major version, used as the image tag |
spec.storage.size |
yes | PVC size, e.g. 1Gi |
spec.storage.storageClassName |
no | Storage class; cluster default when omitted |
spec.resources |
no | Container resource requests/limits |
spec.backup.schedule |
no | Cron schedule for pg_dumpall backups |
spec.backup.s3 |
no | S3 endpoint, bucket, credentialsSecretRef |
Status exposes phase (Pending / Running / Failed), endpoint, and a
Ready condition.
PostgresUser (dbaas.nitroline.dev/v1alpha1):
| Field | Required | Description |
|---|---|---|
spec.instanceRef |
yes | Name of a PostgresInstance in the same namespace |
spec.name |
yes | Role name in PostgreSQL |
Publishes a <name>-credentials Secret (username, password, endpoint,
port) owned by the resource.
PostgresDatabase (dbaas.nitroline.dev/v1alpha1):
| Field | Required | Description |
|---|---|---|
spec.instanceRef |
yes | Name of a PostgresInstance in the same namespace |
spec.name |
yes | Database name |
spec.owner |
no | Owning role; defaults to postgres |
spec.deletionPolicy |
no | Retain (default) keeps the data; Drop drops the DB |
Requires Go 1.24+, Docker, and a running cluster (minikube is fine).
# install the CRD
make install
# build the operator image and run it in the cluster
make docker-build deploy IMG=pg-mini-operator:dev
# create an instance, a user and a database
kubectl apply -f config/samples/
kubectl wait postgresinstance/postgresinstance-sample --for=condition=Ready --timeout=120sDeleting an instance cascades to its owned objects (Secret, Services, StatefulSet,
PVC, backup CronJob) via owner references. Deleting a PostgresUser drops its
role; deleting a PostgresDatabase drops the database only when
deletionPolicy: Drop — both via finalizers.
make undeploy
make uninstallconfig/samples/demo/minio.yaml deploys an in-cluster MinIO (S3-compatible
storage) plus a credentials Secret, so the whole backup path runs with no
external dependencies:
kubectl apply -f config/samples/demo/minio.yaml
docker build -t pg-mini-operator-backup:dev ./backup # build the backup image
# apply an instance whose spec.backup points at http://minio.default.svc:9000
kubectl create job manual-backup --from=cronjob/<instance>-backup # trigger now- Streaming replication and failover.
Run make help for all available targets.
Apache 2.0.