Database

Shepherd keeps everything in PostgreSQL — pipelines, organisations, credentials, sessions — and holds no state of its own. That makes the database the one thing worth backing up, and Shepherd itself something you can delete and reinstall freely.

You have two ways to give it one, and both are fully supported.

Bring your own

The default. Point SHEPHERD_DATABASE_URL at an RDS, a Cloud SQL, an Azure Database, or a Postgres you already run, and put it in the secret the chart reads:

shell
kubectl -n shepherd create secret generic shepherd-secrets \
  --from-literal=SHEPHERD_DATABASE_URL='postgres://user:pass@my-rds.eu-west-1.rds.amazonaws.com:5432/shepherd?sslmode=require' \
  --from-literal=SHEPHERD_SECURITY_ENCRYPTION_KEY="$(openssl rand -base64 32)"

helm install shepherd oci://ghcr.io/procoduck/charts/shepherd --version 0.22.0 \
  --namespace shepherd --set existingSecret=shepherd-secrets

Shepherd needs an ordinary database and an ordinary role that owns it. No superuser, no extensions beyond what a stock PostgreSQL ships, and no manual schema step — migrations run themselves on install and upgrade. Use PostgreSQL 16 or newer: the test suites run 16 and the dev and e2e stacks run 18; older majors are untested.

Without a static password (IRSA, Workload Identity)

On EKS or GKE the connection string above does not have to carry a password at all. Both clouds authenticate a pod to a managed database by annotating the ServiceAccount it runs as, and the chart exposes that:

values.yaml
serviceAccount:
  annotations:
    # EKS, for IAM authentication to RDS
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/shepherd
    # or GKE, for Cloud SQL
    # iam.gke.io/gcp-service-account: shepherd@project.iam.gserviceaccount.com

By default the chart grants this account no RBAC and mounts no API token — Shepherd does not call the Kubernetes API — so it exists precisely to be annotated. (Turning on the receiver tier with receiver.applyTenantRoutes, its default, adds a Role that writes HTTPRoutes in the release namespace and a ClusterRole that reads the HTTPRoute CRD, and mounts the token, so Shepherd can apply tenant routes.) Set serviceAccount.create: false and serviceAccount.name instead to use an account your platform team already manages; it must exist before you install, because the migration Job runs as a pre-install hook and a pod naming a missing ServiceAccount is refused.

Let the chart provision one

If you would rather not run a database by hand, CloudNativePG can run it in the same cluster. Install the operator once, then:

shell
helm install shepherd oci://ghcr.io/procoduck/charts/shepherd --version 0.22.0 \
  --namespace shepherd \
  --set cnpg.enabled=true \
  --set existingSecret=shepherd-secrets   # still holds the encryption key

The chart renders a postgresql.cnpg.io/v1 Cluster and wires Shepherd’s database URL to the uri key of the <cluster>-app Secret the operator generates. You never write a connection string, and SHEPHERD_DATABASE_URL is not needed in your secret at all.

The chart does not install the operator. CloudNativePG and its CRDs must already be in the cluster, exactly as with the Gateway API for route.enabled. Without them the install fails with no matches for kind Cluster.

This path needs Kubernetes 1.29+, which the chart’s own floor of 1.32 already covers: the pinned CloudNativePG operator chart (0.29.1, see deploy/versions.env) refuses older nodes, and the chart declares one supported floor for everyone — see Requirements. A cluster running kindest/node:v1.37.0, the version the chart’s own CI pins for this exact path, is known good.

Three things worth knowing

  • The Cluster is created before the migration Job, as a Helm pre-install hook, because the migration has nothing to talk to otherwise.
  • First install takes a few minutes and the migration Job may fail a few times first. Postgres is still initialising while the Job is already running; the Job retries with backoff (migrations.job.backoffLimit) until it connects. Failed attempts in the log during a first install are expected, not a fault.
  • helm uninstall does not delete the database. Hook resources are not tracked in the release, which for the thing holding all your data is the right way round. Remove it deliberately: kubectl -n shepherd delete cluster shepherd-db.
  • Changing cnpg.* after the first install does nothing. The chart skips rendering a Cluster that already exists, because a hook Helm re-renders is a hook Helm first deletes. Edit a live cluster with kubectl -n shepherd edit cluster shepherd-db, which is the operator’s own interface for it.

Deploying the chart with Argo CD, Flux, or helm template | kubectl apply? Set cnpg.render: never once the database exists. The skip described above uses Helm’s lookup, which needs a live cluster connection. Tools that render the chart offline and apply the output have none, so the guard fails open: the Cluster is emitted on every sync, and Argo CD maps helm.sh/hook: pre-install,pre-upgrade onto a PreSync hook whose default policy is BeforeHookCreation — delete, then recreate. CloudNativePG cascades that deletion to the PVCs, the migration Job migrates a fresh empty database, and the sync reports success. Nothing about the outcome looks like an error.

cnpg.render takes three values. auto (the default) renders only when lookup says the Cluster is absent — correct under the helm CLI, unsafe under anything that renders offline. never never emits it, which is what a GitOps deployment sets after bootstrapping. always emits it unconditionally, for bootstrapping or for a pipeline that wants the full desired state.

Sizing it

values.yaml
cnpg:
  enabled: true
  instances: 3                 # a primary and two standbys
  storage:
    size: 20Gi
    storageClass: fast-ssd
  resources:
    requests: { cpu: 500m, memory: 1Gi }
  # Anything the chart does not surface goes here verbatim: backups,
  # monitoring, affinity, postgresql.parameters.
  extraSpec:
    backup:
      barmanObjectStore:
        destinationPath: s3://my-backups/shepherd

Every key is listed in Helm values. Backup, restore and failover are the operator’s job, not Shepherd’s — see the CloudNativePG documentation.

This path is tested against a real operator, not just rendered: Shepherd’s Kubernetes suite installs CloudNativePG into a throwaway cluster on every change to the chart, installs Shepherd with nothing supplied, and asserts the database is provisioned and the migrations actually reached it.