Skip to content

External PostgreSQL

Use postgresql.kind: external when you already operate PostgreSQL (RDS, Aurora, Cloud SQL via proxy, Azure Database for PostgreSQL, Crunchy/CNPG you manage outside the chart, etc.) and want the platform to consume it instead of rendering Spilo or the dev-only Bitnami subchart.

This is a common enterprise pattern on OpenShift/OKD (avoids the Spilo SCC exception) and in clusters where database operations are owned by a central DBA team.


When to use which backend

postgresql.kindChart rendersBest for
spilo (default)In-cluster Spilo HA + optional PgBouncer + WAL-GProduction on generic Kubernetes; namespace-scoped, no CRDs
bundledSingle-pod Bitnami PostgreSQLDev/lab smoke tests only — no HA, no backups
externalNothing — connection env vars onlyManaged Postgres, existing HA cluster, OpenShift externalize path

Set postgresql.deploy: false whenever you choose external (or spilo).


Requirements

Version and extensions

RequirementDetail
PostgreSQL version15+ recommended (chart default Spilo ships PostgreSQL 17; test upgrades against your target major)
NetworkPlatform pods must reach host:port from the release namespace (security groups, private link, or in-cluster Service if you run Postgres inside the same cluster under a different release)
TLSWhen postgresql.kind: external, set postgresql.external.sslMode (default require) so all platform pods use encrypted connections. Use require for TLS without CA verification (typical for in-cluster operators with self-signed server certs). Use verify-full or verify-ca only when the cluster trusts the server CA (see TLS / SSL mode).
Connection limitSize for PAPI, worker pools, USearch, LiteLLM gateway, and migration jobs concurrently. A managed PgBouncer (or provider pooler) in front of the database is recommended at scale.

Databases

The post-install db-init Job creates four application databases (idempotent):

DatabaseConsumer
corePAPI, Django/FastAPI platform data
usearchUSearch indexer metadata
agentfarmAgentFarm session state
litellmIn-cluster LiteLLM virtual-key / spend tables (when llmGateway.deploy: true)

The core name in postgresql.external.database is the primary application DB env var (DATABASE_NAME); the Job still creates the other three siblings.

Storage sizing

These are planning estimates for the four PostgreSQL databases on a single external instance (same as bundled Spilo, where all four share one data directory). They do not include Neo4j, Redis, object storage, or Mimir — only Postgres.

ProfileWorkspaces / usageTotal Postgres (all four DBs)Notes
POC / lab1–5 workspaces, light task runs, few chat sessions10–20 GBMatches the chart’s default 10 Gi Spilo PVC for smoke tests; leave headroom for WAL and autovacuum
Small production5–20 workspaces, daily SLI/task runs25–50 GBEnable storage autoscaling on RDS/Aurora/Cloud SQL if available
Medium production20–100 workspaces, continuous automation + workspace chat100–250 GBMonitor core first; tune issue/run-session retention in platform policy
Large / multi-team100+ workspaces or high chat + indexing volume250 GB+Capacity review quarterly; consider read replicas and PgBouncer for load, not size

Typical split at steady state (order-of-magnitude; your mix will vary):

DatabaseShare of growthWhat drives size
core~60–75%Run sessions, issues, alerts, activities, workspace config, KB notes, user/org data
usearch~10–20%Indexer job metadata and polling state only — vectors and graph live in Neo4j, not Postgres
agentfarm~10–20%Workspace chat / agent session state; grows with conversational usage
litellm~1–5%Virtual keys and spend accounting when llmGateway.deploy: true; negligible if unused

Fresh install: after migrations, empty schemas are usually well under 1 GB combined. Most capacity planning is about retention and workload, not the base schema.

What to tell your DBA (shared cluster):

  • Provision one logical instance (or four databases on a shared instance) with the total column above — not 4× that number unless your provider bills per database.
  • Request 20–30% headroom above the estimate for WAL, bloat between autovacuum cycles, and index growth.
  • On managed Postgres, turn on storage autoscaling (or set a max above the starting allocation) and automated backups with a retention window that matches compliance — backup snapshots often count against storage or a separate quota depending on the cloud.
  • IOPS / throughput matter as much as GiB: size the instance class for concurrent migration Jobs, PAPI workers, and USearch indexer writes, not disk size alone.

For full platform sizing (nodes, Neo4j, object store), see Deployment Options.

Application user (postgresql.external.username)

The chart uses one PostgreSQL login for everything: the post-install db-init Job, application pods, migration controllers, and LiteLLM (when deployed). There is no separate admin credential for external backends — you cannot hand the chart a superuser Secret and keep the app user least-privileged.

On a shared cluster where your team is not given postgres / cloud-admin credentials, a DBA must provision the role and databases before install. See Shared cluster without admin credentials below.

Privileges the platform needs at runtime

After databases exist, each service runs Alembic (or equivalent) migrations that create and alter tables in the public schema. The application user must be able to run DDL and DML on every database it uses.

DatabaseUsed byMinimum runtime privileges
corePAPI, worker, alerts, activities, …CONNECT; CREATE and USAGE on schema public (or database owner)
usearchUSearch indexer / querySame
agentfarmAgentFarmSame
litellmIn-cluster LiteLLM gateway (llmGateway.deploy: true)Same — still created by db-init even if you use an external LLM proxy; leave empty if unused

Simplest model: make the application user OWNER of all four databases. That satisfies migrations, extensions in public, and day‑2 schema changes without per-object grants.

Least-privilege alternative (DBA-managed): create databases owned by an admin role, then on each database:

GRANT CONNECT ON DATABASE core TO runwhen_app;
\c core
GRANT USAGE, CREATE ON SCHEMA public TO runwhen_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO runwhen_app;

Repeat for usearch, agentfarm, and litellm. The application user still needs CREATE on public so migration Jobs can create tables on first boot.

Privileges required for the db-init Job

For kind: external, db-init connects as postgresql.external.username with PGDATABASE=postgres (the cluster maintenance database). It does not run CREATE ROLE — the login must already exist.

StepWhat db-init doesRequired privilege
ConnectOpens a session to database postgresCONNECT on database postgres
Listpsql -lqt to see if each app DB existsSame session (must see database names you care about)
Create (if missing)createdb -O <app_user> <name> for core, usearch, agentfarm, litellmCREATEDB on the role or databases pre-created so this step is skipped

If all four databases already exist and are owned by the app user, db-init only needs to connect to postgres and confirm they are present — it will not call createdb.

Shared clusters often block postgres. Many managed and multi-tenant Postgres offerings do not grant application users CONNECT on the postgres database. In that case the db-init Job fails even when all four databases are already provisioned, because the chart always starts from PGDATABASE=postgres. See When db-init will not work and the manual bootstrap path below.

Quick reference: privilege profiles

ProfileRole attributesDatabase setupdb-init
Self-service (small external instance)LOGIN, CREATEDBChart creates four DBsWorks
Enterprise / shared clusterLOGIN only (no CREATEDB)DBA creates four DBs, owner = app userWorks if app user has CONNECT on postgres
Strict shared clusterLOGIN onlyDBA creates four DBs; no access to postgresdb-init fails — use manual bootstrap and install with hooks disabled

The chart does not today accept a separate bootstrap user for external Postgres. If you need that, track it as a product request; until then, plan around the table above.

Credentials Secret

The chart expects a Kubernetes Secret named <release>-postgresql-external with key password. Create it before helm upgrade:

Terminal window
kubectl -n runwhen-platform create secret generic rw-platform-postgresql-external \
--from-literal=password='YOUR_STRONG_PASSWORD'

Alternatively, set postgresql.external.password in values for lab installs only — production should use a BYO Secret or ExternalSecrets.


TLS / SSL mode

When postgresql.kind: external, the chart sets TLS on every database consumer:

SurfaceWhat the chart sets
PAPI / worker / USearchDATABASE_SSLMODE in the platform ConfigMap plus PGSSLMODE, PGSSLCERTMODE=disable, and PGSSLCERT="" on pods that connect with libpq env vars
AgentFarm, LiteLLMSESSION_DATABASE_URL / DATABASE_URL with ?sslmode=<mode>&sslcertmode=disable plus the same libpq env vars
db-init, migration init containersPGSSLMODE (and related libpq vars) for psql / createdb

Values knob:

postgresql:
kind: external
deploy: false
external:
host: "mydb.example.com"
port: "5432"
database: "core"
username: "runwhen_app"
sslMode: "require" # default when kind=external; omit to keep default
sslModeBehavior
require (default for external)Encrypt the connection; do not verify the server certificate. Suitable for Zalando/CNPG in-cluster Postgres with self-signed certs.
verify-ca / verify-fullEncrypt and verify the server certificate against a CA. You must mount a trust bundle the pods can read — configure global.trustBundle on the release and ensure your CA is included.
disablePlain TCP (only if your provider and policy allow it).

The chart appends sslcertmode=disable to DSN query strings and sets PGSSLCERTMODE=disable so libpq/psycopg2 do not probe ~/.postgresql/postgresql.crt. Without that, AgentFarm migration controllers can fail with could not open certificate file ".../postgresql.crt": Permission denied even when sslmode=require is correct.

No application code changes are required for require — FastAPI services honor DATABASE_SSLMODE / PGSSLMODE; AgentFarm and LiteLLM use the chart-built DSNs.


Helm configuration example

Minimal overlay for a managed Postgres endpoint:

postgresql:
kind: external
deploy: false
external:
host: "mydb.cluster-abc123.us-east-1.rds.amazonaws.com"
port: "5432"
database: "core"
username: "runwhen_app"
sslMode: "require" # optional; default for kind=external
# password: "" # prefer Secret rw-platform-postgresql-external

After install, confirm the platform ConfigMap picked up the endpoint:

Terminal window
kubectl -n runwhen-platform get cm rw-platform-platform-config -o yaml | rg DATABASE_

Expected keys include DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, and STORAGE_BACKEND_PG_KIND: external.


Example: Amazon RDS / Aurora

postgresql:
kind: external
deploy: false
external:
host: "platform-pg.cluster-ro-abc.us-east-1.rds.amazonaws.com" # writer endpoint
port: "5432"
database: "core"
username: "runwhen_app"

Operator checklist:

  • Security group / NACL allows the platform node pool → RDS on 5432
  • Parameter group compatible with PostgreSQL 15+
  • Allocated storage sized per Storage sizing (autoscaling recommended)
  • DBA created role + four databases (core, usearch, agentfarm, litellm) or app user has CREATEDB
  • App user has CONNECT on postgres (for db-init) or you use --no-hooks after manual bootstrap
  • Secret rw-platform-postgresql-external populated
  • Optional: RDS Proxy or PgBouncer for connection pooling (writer endpoint for bootstrap)

Example: Same cluster, different namespace

If Postgres runs as a separate Helm release in-cluster:

postgresql:
kind: external
deploy: false
external:
host: "postgres.shared-services.svc.cluster.local"
port: "5432"
database: "core"
username: "runwhen_app"

Use the cluster DNS name reachable from the platform namespace — not localhost.

When the operator enforces TLS (for example Zalando Postgres Operator with hostssl in pg_hba.conf), set sslMode: "require":

postgresql:
kind: external
deploy: false
external:
host: "db.my-namespace.svc.cluster.local"
port: "5432"
database: "core"
username: "runwhen"
sslMode: "require"

Shared cluster without admin credentials

Typical when RunWhen installs into a shared PostgreSQL cluster (RDS/Aurora/Cloud SQL/Crunchy/CNPG operated by a central DBA team). You receive an application login and endpoint — not cloud admin or postgres superuser credentials.

What you must ask the DBA to provision

  1. Role — matching postgresql.external.username (example: runwhen_app), with LOGIN and a strong password.
  2. Four databases — exact names: core, usearch, agentfarm, litellm. The chart hard-codes these names in db-init; custom database names are not supported without chart changes.
  3. Ownership — each database OWNER should be the application role (recommended), or equivalent CREATE + USAGE on public as in the table above.
  4. Storage quota — allocate capacity on the shared instance per Storage sizing (total across all four databases, plus headroom for WAL and backups).
  5. CONNECT on postgres — so the db-init Job can connect and no-op when databases already exist. Skip this only if you accept the strict shared cluster install path.

DBA bootstrap script (run as cluster admin)

Replace placeholders before sending to your database team:

-- 1. Application role (password set out-of-band or via your vault)
CREATE ROLE runwhen_app WITH LOGIN PASSWORD 'REPLACE_WITH_STRONG_PASSWORD';
-- 2. Allow db-init to attach to the maintenance DB (listing only; no CREATEDB needed if DBs exist)
GRANT CONNECT ON DATABASE postgres TO runwhen_app;
-- 3. Application databases (names are fixed by the chart)
CREATE DATABASE core OWNER runwhen_app;
CREATE DATABASE usearch OWNER runwhen_app;
CREATE DATABASE agentfarm OWNER runwhen_app;
CREATE DATABASE litellm OWNER runwhen_app;

On providers that forbid GRANT CONNECT ON DATABASE postgres, omit step 2 and use the strict install path instead.

Your install steps (application team)

  1. Create the Kubernetes Secret before helm upgrade:

    Terminal window
    kubectl -n runwhen-platform create secret generic rw-platform-postgresql-external \
    --from-literal=password='SAME_PASSWORD_AS_DBA_SCRIPT'
  2. Point values at the shared endpoint:

    postgresql:
    kind: external
    deploy: false
    external:
    host: "shared-pg.internal.example.com"
    port: "5432"
    database: "core"
    username: "runwhen_app"
  3. Install or upgrade. Confirm db-init completes:

    Terminal window
    kubectl -n runwhen-platform logs job/rw-platform-db-init
  4. Migration init containers (wait-for-db-bootstrap) only need the app user to SELECT 1 on each target database — they do not require superuser access.


When db-init will not work

Use this table during architecture review before you promise a turnkey install on a shared database.

ConditionSymptomWorkaround
Application role does not existpassword authentication failed or role "runwhen_app" does not existDBA creates role; fix Secret password
User lacks CREATEDB and one or more of the four DBs is missingpermission denied to create databaseDBA creates missing DBs or grants CREATEDB
User cannot CONNECT to database postgresJob fails immediately on first psql / psql -lqtDBA grants CONNECT ON DATABASE postgres or use strict path
Wrong Secret name/keyAuth errors referencing wrong userSecret must be <release>-postgresql-external, key password
Network / firewallwait-for-pg init loops foreverOpen host:port from platform namespace to Postgres
Connecting through PgBouncer transaction poolcreatedb errors (DDL in transaction)Point postgresql.external.host at the primary/writer, not a pooler, for bootstrap; pool at runtime separately

Strict path: db-init cannot connect to postgres

Some shared Postgres policies allow CONNECT only on named application databases — not on postgres. The chart’s db-init Job always sets PGDATABASE=postgres, so the Helm post-install hook will fail even when all four databases are correctly provisioned.

Operator workflow when the DBA cannot grant postgres access:

  1. Complete DBA bootstrap without step 2 (GRANT CONNECT ON DATABASE postgres).

  2. Verify the app user can connect to each application database:

    Terminal window
    psql "host=shared-pg.internal dbname=core user=runwhen_app" -c 'SELECT 1'
  3. Install or upgrade with Helm hooks disabled so the failing db-init Job is not created:

    Terminal window
    helm upgrade --install rw-platform runwhen/runwhen-platform \
    -n runwhen-platform -f values.yaml \
    --no-hooks

    Use the same flag on subsequent upgrades until the chart supports external bootstrap without postgres access, or your DBA grants that CONNECT.

  4. Confirm migration init containers pass (wait-for-db-bootstrap uses each app DB directly, not postgres).

  5. Treat db-init Job failure on hook-enabled upgrades as expected in this profile — do not delete application databases trying to “fix” a hook that cannot succeed under your policy.

Product limitation: External Postgres bootstrap uses a single application credential and a fixed postgres maintenance connection. Separate admin credentials for db-init are not configurable in values today.


Features that differ from Spilo

FeatureSpilo (in-chart)External
HA / failoverPatroni in-clusterYour provider
WAL-G backups to S3postgresql.spilo.walg.*Your provider’s backup/PITR
PgBouncerOptional chart-managedBring your own pooler
llmGateway.database.reset JobSupported (kind=spilo only)Not supported — drop/recreate litellm manually
OpenShift SCCSpilo needs anyuid exceptionNo Spilo pod — common reason to externalize

Verify after install

  1. db-init Jobkubectl -n runwhen-platform logs job/rw-platform-db-init
  2. Databases exist — connect with your admin tool and \l (or provider console) for core, usearch, agentfarm, litellm
  3. PAPI readinesskubectl get pods -l app.kubernetes.io/component=papi and /healthz via port-forward
  4. Migrations — migration Jobs / init containers should pass wait-for-db-bootstrap for each database

Troubleshooting

SymptomLikely cause
db-init permission denied to create databaseUser lacks CREATEDB — DBA pre-creates all four DBs or grants CREATEDB
db-init fails on first line / FATAL: no pg_hba.conf entry / cannot connect to postgresApp user lacks CONNECT on maintenance DB — grant it or use --no-hooks after manual bootstrap
password authentication failedSecret name/key mismatch — must be <release>-postgresql-external / password; or role not created
Pods stuck in wait-for-db-bootstrapTarget database missing, wrong host/port, firewall, or app user lacks CONNECT on that DB
AgentFarm / migration controller: could not open certificate file ".../postgresql.crt"External TLS without chart sslcertmode=disable wiring — upgrade to a chart that includes postgresql.external.sslMode helpers, or set sslMode: "require" on kind: external
postgresql.kind=external requires postgresql.external.hostHelm template fail-fast — set external.host before upgrade
Helm upgrade reports hook failure but apps are healthyStrict shared-cluster profile: db-init cannot use postgres — expected unless you use --no-hooks

TopicLink
Self-hosted prerequisitesSelf-Hosted Deployment Requirements
OpenShift external Postgres pathOpenShift / OKD
External S3 (often paired)External S3
Deployment sizingDeployment Options