Kubernetes operator for external PostgreSQL databases - manage AWS Aurora, RDS, and self-hosted databases and users declaratively via CRDs.
Tether your databases to Kubernetes. Create databases and users in existing PostgreSQL clusters through GitOps workflows. Perfect for platform teams building self-service developer experiences.
When building a platform on Kubernetes, I faced a common dilemma with database provisioning:
- CloudNativePG creates databases inside the Kubernetes cluster, requiring PV/PVC management and adding operational complexity
- Crossplane provisions separate database instances per resource, which becomes expensive when you just need multiple databases in a shared cluster
Both are great tools designed for isolated environments — separate clusters or instances per team. But I was building a platform where separation of concerns mattered more than isolation: infrastructure team provisions shared Aurora clusters via Terraform, developers manage their own databases and users via GitOps.
I needed manageability, not isolation. A simple way for developers to self-serve databases without tickets or manual SQL, while infrastructure controls the underlying clusters.
This operator fills that gap. It connects to existing PostgreSQL-compatible clusters (AWS Aurora, RDS, or self-hosted) and manages databases and users declaratively through CRDs. Perfect for Helm charts that need a database, Backstage templates for self-service portals, or ArgoCD workflows where databases are provisioned via pull requests.
As a GitOps enthusiast, this operator fits perfectly into my workflow. I hope it helps others facing the same challenge.
- Manage RDS/Aurora from Kubernetes - connect to existing AWS database clusters and create databases via CRDs
- Self-service database provisioning - developers request databases via pull requests, platform team approves, GitOps applies
- Multi-tenant database management - one Aurora cluster, multiple databases with isolated users per team/namespace
- Database-as-Code with ArgoCD / Flux - declarative database and user management synced from Git
- Ephemeral environments - spin up isolated databases for preview/feature branches via Helm charts, auto-cleanup on teardown
- Declarative management - manage databases and users via Kubernetes CRDs
- GitOps-friendly - works seamlessly with ArgoCD, Flux, and other GitOps tools
- Auto-generated credentials - secure passwords stored in Kubernetes Secrets
- Password rotation - automatic credential rotation with configurable schedule
- Database isolation - users are granted access only to their assigned database (cannot query other databases)
- Configurable deletion policies - choose between Retain (keep data) or Delete on resource removal
- Database backups - one-time and scheduled backups with
pg_dump→ gzip → cloud storage - Database restore - restore from backups with conflict handling (fail, drop, overwrite)
- Multi-cloud storage - backup to AWS S3, Google Cloud Storage, or Azure Blob Storage
- Retention policies - automatic cleanup with
keepLast,keepDaily,keepWeekly,keepMonthly - Cloud-native auth - IRSA, Workload Identity, Managed Identity for secure storage access
helm upgrade -i dbtether oci://ghcr.io/certainty3452/charts/dbtether -n dbtether --create-namespace# Install CRDs
kubectl apply -f config/crd/bases/
# Install RBAC and operator
kubectl apply -f config/rbac/
kubectl apply -f config/manager/docker pull ghcr.io/certainty3452/dbtether:latest
# Multi-arch: linux/amd64, linux/arm64apiVersion: v1
kind: Secret
metadata:
name: aurora-admin-credentials
namespace: postgres-operator-system
type: Opaque
stringData:
username: postgres
password: your-admin-passwordapiVersion: dbtether.io/v1alpha1
kind: DBCluster
metadata:
name: my-aurora-cluster
spec:
endpoint: my-cluster.cluster-xxx.eu-west-1.rds.amazonaws.com
port: 5432
credentialsSecretRef:
name: aurora-admin-credentials
namespace: postgres-operator-systemapiVersion: dbtether.io/v1alpha1
kind: Database
metadata:
name: my-app-db
namespace: default
spec:
clusterRef:
name: my-aurora-cluster
databaseName: my_app
extensions:
- uuid-ossp
- pg_trgm
deletionPolicy: Retain # or DeleteapiVersion: dbtether.io/v1alpha1
kind: DatabaseUser
metadata:
name: my-app-readonly
namespace: default
spec:
database:
name: my-app-db
privileges: readonly# Check cluster connection
kubectl get dbclusters
NAME ENDPOINT PHASE VERSION AGE
my-aurora-cluster my-cluster.xxx.rds.amazonaws.com Connected 15.4 5m
# Check databases
kubectl get databases -A
NAMESPACE NAME CLUSTER DATABASE PHASE AGE
default my-app-db my-aurora-cluster my_app Ready 2m
# Check users
kubectl get databaseusers -A
NAMESPACE NAME DATABASE USERNAME PRIVILEGES PHASE AGE
default my-app-readonly my-app-db my-app-readonly readonly Ready 1m
# Get generated credentials
kubectl get secret my-app-readonly-credentials -o jsonpath='{.data.password}' | base64 -dSee full documentation in docs/:
| CRD | Scope | Description |
|---|---|---|
| DBCluster | Cluster | External PostgreSQL cluster connection |
| Database | Namespaced | Database within a DBCluster |
| DatabaseUser | Namespaced | PostgreSQL user with privileges |
| BackupStorage | Cluster | S3/GCS/Azure storage configuration |
| Backup | Namespaced | One-time database backup |
| BackupSchedule | Namespaced | Scheduled backups with retention policy |
DBCluster:
spec.endpoint- PostgreSQL hostname (required)spec.port- Port, default 5432spec.credentialsSecretRef- Reference to Secret with username/password
Database:
spec.clusterRef.name- Name of DBCluster (required)spec.databaseName- Database name in PostgreSQL (required)spec.extensions- List of PostgreSQL extensionsspec.deletionPolicy-Retain(default) orDelete
DatabaseUser:
spec.database.name- Name of Database (for single database)spec.databases[]- List of databases (for multi-database access)spec.privileges-readonly(default),readwrite,admin, orownerspec.username- PostgreSQL username (defaults to metadata.name)spec.password.length- Password length (default 16, range 12-64)spec.secretGeneration-primary(default) orperDatabasespec.secret.name- Custom secret name (default:{name}-credentials)spec.secret.template- Key format:raw(default),DB,DATABASE,POSTGRES,custom,dsnspec.secret.onConflict- If secret exists:Fail(default),Adopt,Merge
BackupStorage:
spec.s3.bucket- S3 bucket name (required for S3)spec.s3.region- AWS region (required for S3)spec.pathTemplate- Path template (default:{{ .ClusterName }}/{{ .DatabaseName }})spec.credentialsSecretRef- Optional, uses IRSA/Pod Identity if omitted
Backup:
spec.databaseRef.name- Name of Database to backup (required)spec.storageRef.name- Name of BackupStorage (required)spec.filenameTemplate- Filename template (default:{{ .Timestamp }}.sql.gz)spec.trigger- Opaque value that only feeds the spec hash; change it to run the backup againspec.ttlAfterCompletion- Job auto-cleanup duration (default: 1h)
BackupSchedule:
spec.databaseRef.name- Name of Database to backup (required)spec.storageRef.name- Name of BackupStorage (required)spec.schedule- Cron schedule, e.g.,0 2 * * *for 2 AM daily (required)spec.retention.keepLast- Keep N most recent backupsspec.retention.keepDaily- Keep daily backups for N daysspec.suspend- Pause scheduling
Restore:
spec.source.latestFrom.databaseRef.name- Auto-find latest backup for a database (recommended)spec.source.latestFrom.namespace- Namespace to search for backups (optional)spec.source.backupRef.name- Reference to a specific Backup CRDspec.source.path- Direct path to backup file (requiresstorageRef)spec.source.storageRef.name- BackupStorage for direct pathspec.target.databaseRef.name- Target Database to restore into (required)spec.onConflict-fail(default),drop, oroverwritespec.ttlAfterCompletion- Job auto-cleanup duration (default: 1h)
The operator runs with cluster-scoped RBAC. Two grants are worth understanding before installing into a multi-tenant cluster.
The operator's ClusterRole grants get, list, watch, create, update, patch, delete on secrets across all namespaces. This is required to:
- Read
DBCluster.spec.credentialsSecretReffrom any namespace (clusters are cluster-scoped, but their master credentials usually live in a platform namespace). - Write generated
DatabaseUsercredentials into the user's namespace (which is arbitrary). - Manage cross-namespace storage credentials referenced by
BackupStorage.
Blast radius: compromise of the operator ServiceAccount token = read/write of every Secret in the cluster. Treat the operator namespace as a high-trust zone. Concretely:
- Pin the operator namespace as restricted in your admission policy (PSA/OPA).
- Do not co-locate untrusted workloads in the operator's namespace.
- Apply NetworkPolicies to limit egress from the operator pod to your DB endpoints only.
- Rotate the operator's ServiceAccount token if you suspect compromise; cluster-wide Secret access is what an attacker would target.
A namespace-scoped variant (operator only reads/writes Secrets in an allowlisted set of namespaces) is on the roadmap.
DBCluster and BackupStorage are cluster-scoped. Any namespace can today create a Database or DatabaseUser referencing any DBCluster. If you run a shared platform with multiple tenants, see spec.allowedNamespaces on the roadmap — until it lands, gate clusterRef usage with admission policy (Kyverno / Gatekeeper / Validating Webhook).
Backup and restore Jobs run under the same ServiceAccount as the operator. The IRSA / Workload Identity / Managed Identity role attached to it has access to every bucket configured via BackupStorage. If you need per-tenant storage isolation, use a separate operator install per tenant (each with its own ServiceAccount and cloud-IAM binding) rather than one operator with cluster-wide buckets.
Since 0.6.2 the operator probes each BackupStorage on reconcile (every 30 minutes by default, and on every spec change). The probe issues one cheap call against the bucket/container to surface misconfiguration immediately instead of at first backup. This is a strict superset of what 0.6.1 required:
| Provider | Probe call | Required permission |
|---|---|---|
| AWS S3 | HeadBucket |
s3:ListBucket on the bucket |
| GCS | Bucket.Attrs |
storage.buckets.get on the bucket |
| Azure Blob | Container.GetProperties |
container-level Read (covered by Storage Blob Data Reader / Contributor) |
The probe verifies auth and bucket existence, not write access — if the role can list the bucket but lacks s3:PutObject / storage.objects.create / Storage Blob Data Contributor, the probe will report Ready and the first backup will fail. Treat the IAM policies below in the BackupStorage docs as the canonical write-path grants.
Probe failures continuously block new backup jobs (existing jobs continue normally). Transient cloud errors will briefly flip the storage to Failed; it auto-recovers within 60 seconds once the probe succeeds again.
# Build
make build
# Run tests (unit)
make test
# Lint and security checks
make check
# Build multi-arch Docker image
make docker-buildxController tests use envtest which provides a real Kubernetes API server without requiring a full cluster:
# Run all tests including envtest
make test
# Run only controller tests with envtest
make test-envtestRequirements: setup-envtest (installed automatically via go run)
See ROADMAP.md for planned features:
- Database Features — owner, templates, schemas, deletion protection
- Access Control — namespace isolation, validating webhook, IAM authentication
- Secret Management — AWS Secrets Manager, Vault, ESO integration
Contributions are welcome! Whether it's bug reports, feature requests, documentation improvements, or code contributions - I appreciate any help from the community.
Feel free to:
- Open an issue to report bugs or suggest features
- Submit a pull request with improvements
- Share your use cases and feedback
Apache 2.0