PostgreSQL Storage Backend
By default Ark stores resources as Kubernetes CRDs in etcd. Ark also supports a PostgreSQL-backed mode where resources live in a Postgres database and are served via a Kubernetes aggregated API server . This page covers when to choose it, the database requirements, how to install it, and how to operate it.
For the architectural background, see Core Architecture.
When to use PostgreSQL mode
Prefer PostgreSQL when any of these apply:
- Resource scale beyond etcd’s comfort zone. Large fleets of Agents, Models, Queries, MCPServers can push etcd object-count limits and slow the API server.
- Resource-size pressure. etcd’s 1.5 MiB per-object limit is a hard ceiling; Postgres rows are not.
- Persistence and operational tooling. Standard SQL backups, point-in-time recovery, CDC, and BI tooling become available.
- Multi-region or external DB strategy. Managed Postgres (RDS, Cloud SQL, Aiven) is easier to share across clusters than etcd.
Stay on etcd if:
- You want zero database operational burden.
- You don’t need the scale above and prefer the simpler single-binary controller.
Architecture in PostgreSQL mode
Two Helm releases work together:
ark-controller— runs the reconciler. CRDs forark.mckinsey.com/*are not installed in this mode.ark-apiserver— registers as an aggregated API server. The Kubernetes API server proxies allark.mckinsey.comrequests to it; it persists resources to Postgres.
When a user runs kubectl apply -f agent.yaml, the request flow is:
kubectl → kube-apiserver → APIService (v1alpha1.ark.mckinsey.com)
→ ark-apiserver → PostgreSQL (resources table)The controller observes resources through the same K8s API path; it doesn’t talk to Postgres directly.
PostgreSQL requirements
The apiserver creates a logical replication slot to drive its watch stream, so the database must allow logical replication:
wal_level = logical
max_replication_slots >= 1
max_wal_senders >= 1Minimum version
The watch stream decodes the WAL with the built-in pgoutput plugin and a CREATE PUBLICATION, introduced in PostgreSQL 10. The hard minimum is PostgreSQL 13: slot health checks read pg_replication_slots.wal_status and list pagination calls pg_current_snapshot(), neither of which exists in earlier majors. Run a currently-supported major (14 or newer) in production.
A user/role with permission to:
CREATE TABLE,CREATE INDEX,CREATE PUBLICATIONon the target database- the
REPLICATIONattribute (ALTER ROLE ark REPLICATION). Postgres permits replication slots only to roles that hold this attribute, and no predefined role grants it. Managed services often withholdALTER ROLEand expose an equivalent grant instead (for examplerds_replicationon AWS RDS) — check your provider’s documentation.
For managed services:
| Provider | How to enable logical replication |
|---|---|
| AWS RDS | Set rds.logical_replication = 1 in the parameter group, reboot. |
| Google Cloud SQL | Set cloudsql.logical_decoding = on flag, reboot. |
| Azure Database for PostgreSQL | Set wal_level = logical server parameter, restart. |
| Aiven / Neon | Logical replication is on by default. |
The connection settings the chart accepts are listed in ark/dist/chart-apiserver/values.yaml.
Connection pooling and PgBouncer
The apiserver holds a dedicated logical replication connection for its watch stream in addition to its regular query pool. Route only the query traffic through an external pooler, and keep the replication connection direct:
- PgBouncer in transaction or statement pooling mode is incompatible with logical replication. The replication connection must reach Postgres directly (or through a session-pooling/
sessionmode pooler — for PgBouncer this requires version 1.21.0 or newer, which added replication-connection passthrough; older versions reject the replication protocol even in session mode). PointARK_POSTGRES_HOSTat a direct endpoint, or run a separate session-mode pooler for the apiserver. - Managed poolers (RDS Proxy, Cloud SQL connectors) have the same constraint — replication slots require a session-scoped connection.
The apiserver already pools its own query connections (MaxOpenConns defaults to 40); an external transaction-pooling layer in front of the query path is optional and must not carry the replication connection.
Schema and replication slot
On first start, ark-apiserver creates:
- A single table,
resources, with one row per Ark resource (Agent, Model, Query, Team, …). Columns includekind,namespace,name,uid,resource_version, JSONB columns forspec,status,labels,annotations,finalizers,owner_references, plus timestamps and a soft-delete flag (deleted_at). - Indexes on
(kind, namespace),(kind, namespace, name), a GIN index onlabels, and a unique partial index on active (non-deleted) rows. - A publication and a logical replication slot, both named
ark_cdc. The slot is what powerskubectl get -wand controller informers.
The slot is persistent: it survives apiserver restarts. It is dropped automatically on helm uninstall — see Uninstall and cleanup below.
WAL retention
ark_cdc publishes only the resources table, but WAL is cluster-wide: while the slot holds a position, Postgres keeps every segment from that point, including changes for unrelated tables and databases on the same instance. The consumer therefore acknowledges the server’s reported WAL position on each keepalive, not just its own last write, so an idle Ark install does not pin WAL on a busy instance.
Check that the slot is keeping up:
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots WHERE slot_name = 'ark_cdc';retained should stay small. Sustained growth means no replica is consuming the slot — check that a pod holds the ark-apiserver-leader lease and that active is true. Setting max_slot_wal_keep_size caps the damage: Postgres invalidates the slot instead of filling the disk, and the apiserver recreates it on the next restart.
Installing PostgreSQL mode
1. Prepare PostgreSQL
Provision a database with logical replication enabled, create the Ark database and user, and obtain the password.
2. Create the Kubernetes password secret
The chart references the password by secret name; you create it once:
kubectl create namespace ark-system
kubectl create secret generic ark-db-password \
-n ark-system \
--from-literal=password='<your-password>'3. Configure .arkrc.yaml
The CLI reads the backend choice and connection details from .arkrc.yaml. You can place this file in either:
~/.arkrc.yaml(user-level, applies to all projects)./.arkrc.yaml(project-level, takes precedence)
# .arkrc.yaml
storage:
backend: postgresql
postgresql:
host: ark-storage.example.com
port: 5432
database: ark
user: ark
passwordSecretName: ark-db-password
passwordSecretKey: password
sslMode: requiresslMode accepts the standard libpq values: disable, require, verify-ca, verify-full. The default is require, so the controller→database connection is encrypted out of the box; set disable explicitly for a non-TLS database (e.g. local dev).
require encrypts the connection but does not verify the server’s identity, so it only protects against a passive eavesdropper on the wire. An active attacker who can present any TLS certificate — a rogue pod, DNS misroute, or MITM — can still impersonate the database. For production, use verify-full, which validates the server certificate and hostname; verify-ca validates the certificate but not the hostname. Against a publicly-trusted managed database (RDS, Cloud SQL) they work with no extra configuration because the controller image trusts the system CA bundle. For a private CA, self-signed certificate, or mutual TLS, mount the PEM files via a Secret on the apiserver chart:
kubectl create secret generic ark-pg-tls \
--namespace ark-system \
--from-file=ca.crt=ca.crt \
--from-file=tls.crt=client.crt \
--from-file=tls.key=client.key
helm upgrade --install ark-apiserver \
oci://ghcr.io/mckinsey/agents-at-scale-ark/charts/ark-apiserver \
--namespace ark-system \
--set postgresql.host=ark-storage.example.com \
--set postgresql.user=ark \
--set postgresql.passwordSecretName=ark-db-password \
--set postgresql.sslMode=verify-full \
--set postgresql.sslSecretName=ark-pg-tls \
--set postgresql.sslRootCertKey=ca.crt \
--set postgresql.sslClientCertKey=tls.crt \
--set postgresql.sslClientKeyKey=tls.keyThe Secret is mounted read-only at /etc/ark/postgres-tls and the named keys are passed to libpq as sslrootcert/sslcert/sslkey. Only set the client cert/key keys if the database requires client-certificate authentication; sslRootCertKey alone is enough for verify-ca/verify-full against a private CA.
The --backend CLI flag and ARK_STORAGE_BACKEND env var override the config value, useful for testing the same code against a different backend without editing the file.
4. Install via the CLI
ark installThe CLI installs ark-controller with storage.backend=postgresql (which disables CRD installation) and ark-apiserver with the connection values from the config. cert-manager and Gateway API CRDs are installed as dependencies just as in etcd mode.
Install via raw Helm
If you prefer to skip the CLI, install the two charts directly with --set flags from the values you would have put in .arkrc.yaml:
helm upgrade --install ark-controller \
oci://ghcr.io/mckinsey/agents-at-scale-ark/charts/ark-controller \
--namespace ark-system --create-namespace \
--set rbac.enable=true \
--set storage.backend=postgresql
helm upgrade --install ark-apiserver \
oci://ghcr.io/mckinsey/agents-at-scale-ark/charts/ark-apiserver \
--namespace ark-system \
--set postgresql.host=ark-storage.example.com \
--set postgresql.user=ark \
--set postgresql.passwordSecretName=ark-db-password \
--set postgresql.sslMode=requireThe apiserver chart’s postgresql.host, postgresql.user, and postgresql.passwordSecretName are required values — helm install will fail at template time if they are missing.
Verifying the install
# No Ark CRDs in postgresql mode.
kubectl get crd | grep ark.mckinsey.com
# (no output)
# Both APIServices should report Available=True.
kubectl get apiservice v1alpha1.ark.mckinsey.com v1prealpha1.ark.mckinsey.com
# kubectl operates on Ark resources transparently.
kubectl get agents,models,queries -ACreate a smoke-test Agent to confirm the round trip lands in Postgres:
kubectl apply -f - <<EOF
apiVersion: ark.mckinsey.com/v1alpha1
kind: Agent
metadata:
name: smoke
namespace: default
spec:
description: smoke test
prompt: "You are a helpful assistant."
EOFThen connect to Postgres and confirm the row exists:
SELECT kind, namespace, name, uid FROM resources WHERE kind = 'Agent';Security model
The aggregated apiserver enforces the same access control as the rest of the cluster:
- Delegated authentication and authorization. Every request is authenticated against the kube-apiserver (TokenReview and requestheader/front-proxy identity) and authorized via SubjectAccessReview, so Kubernetes RBAC on
ark.mckinsey.comresources applies to direct service access as well as to the kubectl path. Health endpoints (/healthz,/readyz,/livez) stay unauthenticated. SetARK_APISERVER_AUTH_MODE=off(for example viaextraEnv) only for local development outside a cluster. - Verified serving TLS. With
certManager.enabled(the default), cert-manager issues the serving certificate for theark-apiserverservice, mounts it into the pod, and injects the CA into both APIServices — the kube-apiserver verifies the aggregated apiserver’s identity instead ofinsecureSkipTLSVerify. The certificate rotates through cert-manager and the server reloads it without a restart. SettingcertManager.enabled=falserestores the previous behavior (ephemeral self-signed certificate, unverified proxy channel) for clusters without cert-manager. - Optional NetworkPolicy.
networkPolicy.enabled=truerestricts ingress to the serving and health ports; usenetworkPolicy.extraIngressFromto pin the allowed sources for the serving port (the origin of kube-apiserver traffic depends on your CNI, which is why this is off by default). - Audit log.
audit.enabled(defaulttrue) makes the aggregated apiserver emit its own Kubernetes audit trail as JSON on stdout, atMetadatalevel by default. Because the records are produced in-process they cover the direct service path too. A policy file is mandatory when audit is enabled — without one the upstream backend records nothing, so the apiserver refuses to start rather than report audit as active while emitting nothing; the chart always mounts one. Tune withaudit.levelor supply a fullaudit.policy. Note thatRequest/RequestResponselevels put request and response bodies — including Query.spec.input— into your log pipeline. - Policy enforcement. The host kube-apiserver runs no webhook chain on aggregated resources, so this apiserver enforces policy itself, two independent ways. A native
ValidatingAdmissionPolicy(CEL) is evaluated in-process (policy.cel.enabled, defaulttrue) and requires the host cluster at k8s ≥1.30. The webhook admission plugins can also be run here (policy.thirdPartyWebhooks.enabled, defaultfalse), which makes Kyverno/Gatekeeper webhook configurations fire on Ark resources; it is off by default because it puts a synchronous webhook call on every write. Each mechanism is best-effort by default: on an older host, if the startup discovery probe never succeeds, or if the ServiceAccount lacks the watch RBAC, the apiserver logs the reason and serves with that mechanism off (Ark’s own in-process validation and audit still apply). Setpolicy.cel.required=trueorpolicy.thirdPartyWebhooks.required=trueto make that a startup failure instead. Policies that useparamKindneed read access to the parameter resource viapolicy.extraParamRulesunless the parameters live in ConfigMaps or Secrets, which are already granted.
Availability and multi-replica behaviour
The chart defaults to a single replica. Understand what that means before running it in production: when the aggregated apiserver is unavailable, the ark.mckinsey.com APIService goes unavailable, which degrades kube-apiserver API discovery and the garbage-collection and namespace controllers cluster-wide — a standard Kubernetes aggregation-layer failure mode, not something specific to Ark. A single replica is therefore a cluster-wide single point of failure during pod restarts, node drains, and crashes.
For production, run two replicas and enable the disruption budget:
--set replicas=2 --set podDisruptionBudget.enabled=trueHow multiple replicas behave:
- API serving is active-active. Every replica serves reads and writes directly against Postgres; the Service load-balances across them. Killing any one pod leaves the APIService available.
- The watch stream is single-consumer by design. Only the replica holding the
ark-apiserver-leaderlease runs the WAL consumer that drives real-time watch events (the logical replication slot admits one connection; the slot’sactiveflag is a backstop even without the lease). Non-leader replicas do not touch the slot. - Watches served by non-leader replicas are relist-bound. Without the WAL stream, a non-leader refreshes its watchers on a periodic relist (up to ~120 seconds stale). Controllers tolerate this — informers resync — but if consistently low watch latency matters, keep a single replica and accept the SPOF trade-off, or route watch-heavy clients through the kubectl path.
- Failover: if the leader dies, a surviving replica acquires the lease (roughly the 15s lease duration) and starts the WAL consumer; the slot position is persistent, so no events are lost across the handover.
Backups and restore
Treat the resources table like any other application table:
- Use your provider’s automated backups or
pg_dumpfor ad-hoc snapshots. - A point-in-time restore restores Ark state to that moment. Take care to also drop and recreate the
ark_cdcreplication slot after a restore so the apiserver starts a fresh watch stream. - Cluster-side state (Pods, Deployments owned by Ark) is not restored by a Postgres restore — only the declarative resources are.
Uninstall and cleanup
helm uninstall runs a post-delete hook Job that drops the ark_cdc replication slot and publication. Without this cleanup, an orphaned slot would pin WAL retention and can fill the disk. The Job connects with the same credentials as the apiserver, so the password secret must still exist when you uninstall.
Confirm in the database that the slot is gone rather than trusting the Job’s log line — both queries should return no rows:
SELECT slot_name, active, wal_status FROM pg_replication_slots WHERE slot_name = 'ark_cdc';
SELECT pubname FROM pg_publication WHERE pubname = 'ark_cdc';If the hook Job could not run (for example the database was unreachable, you removed the release with --no-hooks, or the password secret was deleted first), drop the slot manually:
SELECT pg_drop_replication_slot('ark_cdc');
DROP PUBLICATION IF EXISTS ark_cdc;If the slot was invalidated (wal_status = 'lost', typically after max_slot_wal_keep_size was exceeded), the apiserver drops and recreates it automatically on startup.
The resources table itself is yours — drop it manually if you are decommissioning the database, or keep it for forensic queries.
Troubleshooting
helm install fails with postgresql.host is required. You ran the apiserver chart without supplying connection details. Set storage.postgresql in .arkrc.yaml (the CLI passes these through) or use --set postgresql.host=… --set postgresql.user=… --set postgresql.passwordSecretName=… for raw helm.
ark install fails with missing 'storage.postgresql' block. You set storage.backend: postgresql in .arkrc.yaml but didn’t add the storage.postgresql block, or it’s missing host/user/passwordSecretName. Fill in the required fields.
Apiserver pod is CreateContainerConfigError. The pod is referencing a secret that doesn’t exist. Confirm the secret named in postgresql.passwordSecretName exists in the same namespace as the release.
Apiserver crashes with failed to connect to database: dial tcp … connect: connection refused. The host/port is wrong, the database isn’t up yet, or a NetworkPolicy is blocking egress. The pod will restart and retry.
Apiserver logs error retrieving resource lock. Leader election can’t reach the Kubernetes API. Usually a transient startup issue; if it persists, check the ServiceAccount, RBAC bindings, and any egress restrictions.
APIService stuck at False (FailedDiscoveryCheck). The aggregator can’t reach the apiserver Service. Check kubectl get svc -n ark-system ark-apiserver, verify pods are 1/1, and confirm no NetworkPolicy blocks port 6443 from the kube-apiserver.
kubectl get agents returns “the server doesn’t have a resource type …”. The APIService is not registered or not Available. Look at kubectl get apiservice v1alpha1.ark.mckinsey.com -o yaml.
Resources don’t appear after kubectl apply, but no error. Check kubectl get events -A. Confirm the apiserver pod is running and the replication slot exists in Postgres (SELECT slot_name, active, wal_status FROM pg_replication_slots).