Skip to main content

PostgreSQL Backend

Self-Hosted Kubernetes

By default, AIControls stores audit events, policies, HITL requests, and budgets in an embedded SQLite database on a PVC. SQLite is fine for testing. Switch to PostgreSQL for production when you need more than one replica.

When to use PostgreSQL

The default SQLite backend is a single file on a ReadWriteOnce PVC — it only supports replicaCount: 1. Two SQLite-backed replicas writing to the same PVC will corrupt state.

Use PostgreSQL when you need:

  • High availability — run replicaCount > 1 behind the Gateway/Ingress so a pod restart or node drain doesn't cause a gap in enforcement.
  • Larger audit volumes — PostgreSQL handles concurrent writers natively; AIControls does not serialize writes behind an in-process lock the way it does for SQLite.

If you're running a single replica for evaluation or a small team, SQLite is simpler and requires no external dependency — there's no need to switch.

Prerequisites

  • A reachable PostgreSQL 12+ database (any managed offering — RDS, Cloud SQL, Azure Database for PostgreSQL — or a self-managed instance). AIControls does not bundle or manage a Postgres instance itself.
  • A Kubernetes Secret containing the connection string, created in the same namespace as the AIControls release.

Configure the backend

  1. Create the DSN Secret. The chart never renders connection strings into manifests — create the Secret yourself, referencing your database:

    kubectl create secret generic aicontrols-db-dsn \
    -n aicontrols \
    --from-literal=dsn="postgres://USER:PASSWORD@HOST:5432/aicontrols?sslmode=require"
  2. Point values.yaml at the Secret:

    storage:
    # No PVC needed — state lives in PostgreSQL.
    persistentVolume:
    enabled: false
    database:
    driver: postgres
    # Name of the env var the proxy reads the DSN from.
    dsnEnv: AICONTROLS_DB_DSN
    dsnSecretName: aicontrols-db-dsn
    dsnSecretKey: dsn

    replicaCount: 2
  3. Install or upgrade:

    helm upgrade --install aicontrols \
    oci://ghcr.io/nirmata/charts/aicontrols \
    --version $VERSION \
    -n aicontrols \
    -f values.yaml
  4. Verify. The schema is created automatically on first connect — there is no separate migration step to run. Confirm both replicas came up and are serving traffic:

    kubectl get pods -n aicontrols
    kubectl logs -n aicontrols deploy/aicontrols --tail=50

High availability with multiple replicas

With replicaCount > 1, every pod connects to the same PostgreSQL database and shares audit history, policies, budgets, and HITL state. A small set of background jobs (risk scoring, PAT/credential cleanup, cost aggregation, digest scheduling) must only run on one replica at a time — AIControls coordinates this automatically using a lease table in the database, so no extra configuration is needed.

SQLite stays single-replica

The lease-based coordination only applies to the PostgreSQL backend. If you're on SQLite, keep replicaCount: 1.

Session/JWT signing secret

Every replica must sign JWTs and session cookies with the same secret — otherwise a request handled by a different pod than the one that issued the session invalidates it. The chart handles this automatically: it generates one secret on first install and stores it in a <release>-jwt Secret, reused by every replica and stable across helm upgrade (the same idempotent lookup pattern used for the bootstrap admin password). No configuration is required for the default case.

To use an externally managed secret instead, set:

auth:
jwtSecretName: my-jwt-secret # Secret must contain a key named "secret" (or set jwtSecretKey)

The referenced key's value must be base64 text that decodes to at least 16 bytes — e.g. openssl rand -base64 32. Kubernetes env vars can't reliably carry arbitrary binary (notably embedded NUL bytes), so raw/unencoded bytes are not a safe value here even though the proxy falls back to treating a non-base64 value as raw bytes.

Rotation

Rotating the JWT secret (e.g. deleting the chart-managed <release>-jwt Secret) invalidates every active session and signed token across all replicas at once.

Virtual-key encryption secret

Secrets saved through the UI (for example, an LLM upstream API key added under Settings → LLM Models) are encrypted at rest with an AES-256 key before being written to PostgreSQL. Every replica must use the same key — otherwise a secret saved via one pod fails to decrypt on every other pod. The chart handles this automatically: it generates one 32-byte key on first install and stores it in a <fullname>-encryption Secret (e.g. <release>-aicontrols-encryption by default), reused by every replica and stable across helm upgrade (the same idempotent lookup pattern used for the bootstrap admin password).

To use an externally managed secret instead, set:

auth:
encryptionKeySecretName: my-encryption-secret # Secret must contain a key named "key" (or set encryptionKeySecretKey)
# The referenced key's value must be base64 TEXT decoding to exactly 32 raw
# bytes (stricter than the JWT/session secret above, which tolerates raw
# bytes or any length >= 16) — e.g. generate one with: openssl rand -base64 32
Rotation causes permanent data loss

Unlike the JWT/session-signing secret above (rotation only invalidates sessions), rotating or deleting the encryption secret (the chart-managed <fullname>-encryption Secret, or whatever auth.encryptionKeySecretName points at) is not recoverable: every value previously encrypted with the old key — including saved LLM upstream API keys — becomes permanently undecryptable. There is no "log in again" recovery path. Do not delete or regenerate this secret unless you have already re-saved every encrypted value under the new key.

Upgrading from a chart version without this fix

If your install predates this shared secret, each replica previously generated its own local key under /data/encryption.key. Any secret saved before upgrading was encrypted with a pod-local key that no other replica shares — after upgrading, re-save those secrets (e.g. re-enter LLM upstream API keys under Settings → LLM Models) so they are encrypted under the new shared key instead.