Skip to content

Control-plane config & secrets

Basic backend settings, required secrets, and production configuration reminders.

The backend is configured through environment variables: typically a ConfigMap for the non-secret ones and a Secret for the rest. Two are mandatory and the process fails fast without them. There are no production-safe defaults for a signing key or an encryption key.

Basic environment variables

This is a starter list. Set EXO_DEV_MODE=false in production (it defaults to true). Multiple replicas also need EXO_PEER_SERVICE and a shared EXO_INTERNAL_SECRET. Recording storage is covered under Database; public app access under Apps. For the full configuration list, see backend/config/config.go in your release source.

VariableRequiredDefaultPurposeExample
EXO_ENCRYPTION_KEY✓ yes(none)AES-256 root key, exactly 32 bytes9f2a4c6e8b0d1f3a5c7e9b1d3f5a7c9e
EXO_JWT_SECRET✓ yesrequired — no defaultHS256 key for user + OAuth tokensxK9wL2mN…
EXO_DATABASE_DSN✓ prodlocalhost dev DSNPostgres connection stringpostgres://exo:…@db.acme.internal:5432/exo
EXO_PORTno9092HTTP listen port9092
EXO_BASE_URLnohttp://localhost:9092Public URL (email links, OAuth cb)https://exo.acme.com
EXO_JWT_EXPIRYno24hUser JWT lifetime (Go duration)24h
EXO_CAST_STORAGE_DIRno/var/lib/exo/castsSession-recording dir (writable vol)/var/lib/exo/casts
EXO_CLAUDE_API_KEYno(empty)base64 Anthropic key, AI fallbackc2stYW50LWFwaTAzL…

EXO_ENCRYPTION_KEY

Root of the envelope-encryption keyring. It wraps the key-encryption key, which in turn encrypts every sensitive column at rest: integration credentials, identity-provider secrets, log-sink configs, tool permissions, session payloads. A database dump is useless without it.

Hard requirement, exact length
It must be exactly 32 bytes (AES-256). There's deliberately no fallback; a known hardcoded key is worse than failing fast. If it's unset or the wrong length, the backend exits at boot with EXO_ENCRYPTION_KEY is required (must be a 32-byte AES-256 key). Lose it after data is written and that data can't be decrypted again. Store it in a vault and back it up separately from the database.

EXO_JWT_SECRET

Signs every user JWT and every short-lived OAuth access token (HS256). Rotating it invalidates all active sessions and issued tokens immediately, so treat it like a database password: vault it, rotate on a schedule, never commit it. There is no default: EXO_JWT_SECRET is required, and the well-known value changeme-secret-key is rejected at boot so a forgeable instance can't start.

EXO_CLAUDE_API_KEY

Optional. A platform-default Anthropic key used by the "Generate with AI" features (skill generation) when a tenant hasn't configured its own provider. It must be base64-encoded in the Secret; the backend decodes it at read time. Leave it blank to disable the fallback. In production, prefer Claude Workload Identity Federation over a static key.

terminal· bash
printf '%s' 'sk-ant-...' | base64

Generating secrets

If you use exo-install, the JWT secret, encryption key, and (with the bundled Postgres) the database password are generated for you. The Helm chart uses supplied values first, then reuses the existing Secret, and generates missing secrets otherwise. Preserve the Secret on upgrades and back it up, especially the encryption key. For manual installs:

terminal· bash
EXO_ENCRYPTION_KEY="$(openssl rand -hex 16)"
EXO_JWT_SECRET="$(openssl rand -base64 48)"
kubectl -n exo create secret generic exo-secrets \
--from-literal=EXO_ENCRYPTION_KEY="$EXO_ENCRYPTION_KEY" \
--from-literal=EXO_JWT_SECRET="$EXO_JWT_SECRET" \
--from-literal=EXO_DATABASE_DSN="host=db user=exo dbname=exo sslmode=require"
Why hex 16
openssl rand -hex 16 emits 32 hex characters, exactly 32 bytes as a string, which is what the key check wants. Avoid base64 32 here: it produces ~44 characters and fails the 32-byte check.