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.kind | Chart renders | Best for |
|---|---|---|
spilo (default) | In-cluster Spilo HA + optional PgBouncer + WAL-G | Production on generic Kubernetes; namespace-scoped, no CRDs |
bundled | Single-pod Bitnami PostgreSQL | Dev/lab smoke tests only — no HA, no backups |
external | Nothing — connection env vars only | Managed Postgres, existing HA cluster, OpenShift externalize path |
Set postgresql.deploy: false whenever you choose external (or spilo).
Requirements
Version and extensions
| Requirement | Detail |
|---|---|
| PostgreSQL version | 15+ recommended (chart default Spilo ships PostgreSQL 17; test upgrades against your target major) |
| Network | Platform 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) |
| TLS | When 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 limit | Size 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):
| Database | Consumer |
|---|---|
core | PAPI, Django/FastAPI platform data |
usearch | USearch indexer metadata |
agentfarm | AgentFarm session state |
litellm | In-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.
| Profile | Workspaces / usage | Total Postgres (all four DBs) | Notes |
|---|---|---|---|
| POC / lab | 1–5 workspaces, light task runs, few chat sessions | 10–20 GB | Matches the chart’s default 10 Gi Spilo PVC for smoke tests; leave headroom for WAL and autovacuum |
| Small production | 5–20 workspaces, daily SLI/task runs | 25–50 GB | Enable storage autoscaling on RDS/Aurora/Cloud SQL if available |
| Medium production | 20–100 workspaces, continuous automation + workspace chat | 100–250 GB | Monitor core first; tune issue/run-session retention in platform policy |
| Large / multi-team | 100+ workspaces or high chat + indexing volume | 250 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):
| Database | Share of growth | What 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.
| Database | Used by | Minimum runtime privileges |
|---|---|---|
core | PAPI, worker, alerts, activities, … | CONNECT; CREATE and USAGE on schema public (or database owner) |
usearch | USearch indexer / query | Same |
agentfarm | AgentFarm | Same |
litellm | In-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 coreGRANT 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.
| Step | What db-init does | Required privilege |
|---|---|---|
| Connect | Opens a session to database postgres | CONNECT on database postgres |
| List | psql -lqt to see if each app DB exists | Same session (must see database names you care about) |
| Create (if missing) | createdb -O <app_user> <name> for core, usearch, agentfarm, litellm | CREATEDB 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 usersCONNECTon thepostgresdatabase. In that case thedb-initJob fails even when all four databases are already provisioned, because the chart always starts fromPGDATABASE=postgres. See Whendb-initwill not work and the manual bootstrap path below.
Quick reference: privilege profiles
| Profile | Role attributes | Database setup | db-init |
|---|---|---|---|
| Self-service (small external instance) | LOGIN, CREATEDB | Chart creates four DBs | Works |
| Enterprise / shared cluster | LOGIN only (no CREATEDB) | DBA creates four DBs, owner = app user | Works if app user has CONNECT on postgres |
| Strict shared cluster | LOGIN only | DBA creates four DBs; no access to postgres | db-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:
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:
| Surface | What the chart sets |
|---|---|
| PAPI / worker / USearch | DATABASE_SSLMODE in the platform ConfigMap plus PGSSLMODE, PGSSLCERTMODE=disable, and PGSSLCERT="" on pods that connect with libpq env vars |
| AgentFarm, LiteLLM | SESSION_DATABASE_URL / DATABASE_URL with ?sslmode=<mode>&sslcertmode=disable plus the same libpq env vars |
db-init, migration init containers | PGSSLMODE (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 defaultsslMode | Behavior |
|---|---|
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-full | Encrypt 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. |
disable | Plain 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-externalAfter install, confirm the platform ConfigMap picked up the endpoint:
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 hasCREATEDB - App user has
CONNECTonpostgres(fordb-init) or you use--no-hooksafter manual bootstrap - Secret
rw-platform-postgresql-externalpopulated - 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
- Role — matching
postgresql.external.username(example:runwhen_app), withLOGINand a strong password. - Four databases — exact names:
core,usearch,agentfarm,litellm. The chart hard-codes these names indb-init; custom database names are not supported without chart changes. - Ownership — each database
OWNERshould be the application role (recommended), or equivalentCREATE+USAGEonpublicas in the table above. - Storage quota — allocate capacity on the shared instance per Storage sizing (total across all four databases, plus headroom for WAL and backups).
CONNECTonpostgres— so thedb-initJob 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)
-
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' -
Point values at the shared endpoint:
postgresql:kind: externaldeploy: falseexternal:host: "shared-pg.internal.example.com"port: "5432"database: "core"username: "runwhen_app" -
Install or upgrade. Confirm
db-initcompletes:Terminal window kubectl -n runwhen-platform logs job/rw-platform-db-init -
Migration init containers (
wait-for-db-bootstrap) only need the app user toSELECT 1on 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.
| Condition | Symptom | Workaround |
|---|---|---|
| Application role does not exist | password authentication failed or role "runwhen_app" does not exist | DBA creates role; fix Secret password |
User lacks CREATEDB and one or more of the four DBs is missing | permission denied to create database | DBA creates missing DBs or grants CREATEDB |
User cannot CONNECT to database postgres | Job fails immediately on first psql / psql -lqt | DBA grants CONNECT ON DATABASE postgres or use strict path |
| Wrong Secret name/key | Auth errors referencing wrong user | Secret must be <release>-postgresql-external, key password |
| Network / firewall | wait-for-pg init loops forever | Open host:port from platform namespace to Postgres |
| Connecting through PgBouncer transaction pool | createdb 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:
-
Complete DBA bootstrap without step 2 (
GRANT CONNECT ON DATABASE postgres). -
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' -
Install or upgrade with Helm hooks disabled so the failing
db-initJob is not created:Terminal window helm upgrade --install rw-platform runwhen/runwhen-platform \-n runwhen-platform -f values.yaml \--no-hooksUse the same flag on subsequent upgrades until the chart supports external bootstrap without
postgresaccess, or your DBA grants thatCONNECT. -
Confirm migration init containers pass (
wait-for-db-bootstrapuses each app DB directly, notpostgres). -
Treat
db-initJob 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
postgresmaintenance connection. Separate admin credentials fordb-initare not configurable in values today.
Features that differ from Spilo
| Feature | Spilo (in-chart) | External |
|---|---|---|
| HA / failover | Patroni in-cluster | Your provider |
| WAL-G backups to S3 | postgresql.spilo.walg.* | Your provider’s backup/PITR |
| PgBouncer | Optional chart-managed | Bring your own pooler |
llmGateway.database.reset Job | Supported (kind=spilo only) | Not supported — drop/recreate litellm manually |
| OpenShift SCC | Spilo needs anyuid exception | No Spilo pod — common reason to externalize |
Verify after install
db-initJob —kubectl -n runwhen-platform logs job/rw-platform-db-init- Databases exist — connect with your admin tool and
\l(or provider console) forcore,usearch,agentfarm,litellm - PAPI readiness —
kubectl get pods -l app.kubernetes.io/component=papiand/healthzvia port-forward - Migrations — migration Jobs / init containers should pass
wait-for-db-bootstrapfor each database
Troubleshooting
| Symptom | Likely cause |
|---|---|
db-init permission denied to create database | User 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 postgres | App user lacks CONNECT on maintenance DB — grant it or use --no-hooks after manual bootstrap |
password authentication failed | Secret name/key mismatch — must be <release>-postgresql-external / password; or role not created |
Pods stuck in wait-for-db-bootstrap | Target 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.host | Helm template fail-fast — set external.host before upgrade |
| Helm upgrade reports hook failure but apps are healthy | Strict shared-cluster profile: db-init cannot use postgres — expected unless you use --no-hooks |
Related documentation
| Topic | Link |
|---|---|
| Self-hosted prerequisites | Self-Hosted Deployment Requirements |
| OpenShift external Postgres path | OpenShift / OKD |
| External S3 (often paired) | External S3 |
| Deployment sizing | Deployment Options |