Configuration
RealmSSO is configured entirely through environment variables, validated once at boot.
All configuration lives in environment variables, validated by a Zod schema the moment the process starts. If a required variable is missing or malformed, the server prints exactly which ones failed and exits before serving any traffic. The server repo's .env.example lists every variable with dev-safe placeholder values — treat every value in it as unsafe to reuse in production.
Server & data stores
| Variable | Default | Notes |
|---|---|---|
| NODE_ENV / PORT / HOST / LOG_LEVEL | development / 4000 / 0.0.0.0 / info | Basic runtime config |
| DATABASE_URL | required, no default | PostgreSQL connection string |
| REDIS_URL | redis://localhost:6379 | Rate limiting and caching |
| PUBLIC_BASE_URL | http://localhost:4000 | RealmSSO's own public origin, used to build SP-side URLs handed to customers |
Keycloak
| Variable | Default | Notes |
|---|---|---|
| KEYCLOAK_ADMIN_URL | required, no default | Server-to-server admin API URL — often internal cluster DNS |
| KEYCLOAK_PUBLIC_URL | falls back to KEYCLOAK_ADMIN_URL | Browser-facing origin — must differ whenever the admin URL is internal-only |
| KEYCLOAK_REALM | realmsso | Base realm, and prefix for every per-account realm (<KEYCLOAK_REALM>-<slug>) |
| KEYCLOAK_AUTH_REALM | master | The realm the admin service account authenticates against — not the realm it manages. Leave it at master for the bundled setup. For a bring-your-own-Keycloak install the service account often lives in KEYCLOAK_REALM itself, in which case set this to the same value. |
| KEYCLOAK_ADMIN_CLIENT_ID / SECRET | realmsso-admin / optional | Service account credentials |
| KEYCLOAK_ALLOW_REALM_CREATION | true | Must stay true for account provisioning to work — see Self-Hosting Guide |
Auth & sessions
| Variable | Default | Notes |
|---|---|---|
| JWT_SECRET | required, min 32 chars | Signs vendor dashboard JWTs — generate with openssl rand -hex 64 |
| MAGIC_LINK_SECRET / EXPIRY_MINUTES | required / 15 | Signs magic-link tokens and how long they stay valid |
| REFRESH_TOKEN_EXPIRY_DAYS | 30 | Vendor dashboard refresh token lifetime |
| ENCRYPTION_KEYS | required | The AES-256-GCM key ring encrypting client secrets at rest. A comma-separated list of <id>:<key> entries; a key is exactly 64 hex characters (openssl rand -hex 32) — see Encryption at Rest |
| ENCRYPTION_CURRENT_KEY_ID | required | The id from ENCRYPTION_KEYS that NEW rows are encrypted under (e.g. k1) — never the key itself. Every other entry is still read |
| ENCRYPTION_KEY | RETIRED — refused at boot | The single-key predecessor of the two above. The server refuses to start if it is still set, rather than ignoring it — see Encryption at Rest |
Admin Portal & Dashboard
| Variable | Default | Notes |
|---|---|---|
| ADMIN_PORTAL_TOKEN_EXPIRY_MINUTES | 30 | How long a customer Admin Portal access link stays valid |
| ADMIN_PORTAL_BASE_URL | http://localhost:4000/admin | Base URL portal links are built against |
| DASHBOARD_BASE_URL | http://localhost:3000 | Vendor dashboard origin — magic-link emails point at ${DASHBOARD_BASE_URL}/auth/callback |
Set these explicitly — the built-in defaults are placeholders
Both URLs are paths on the realmsso-app frontend (which serves the dashboard and the Admin Portal), not on the API and not on this marketing site. Always override them to that frontend's real origin — see Architecture Overview for how the repos and hostnames divide up.
SMTP
| Variable | Default | Notes |
|---|---|---|
| SMTP_HOST / PORT / USER / PASS | unset / 587 / unset / unset | Leave unset in development — a JSON transport logs the message instead of sending it |
| SMTP_FROM | placeholder noreply@ address | Set to an address on a domain you control, or mail fails SPF/DKIM |
CORS, rate limiting, webhooks, retention, background jobs
| Variable | Default | Notes |
|---|---|---|
| CORS_ORIGINS | http://localhost:3000 | Comma-separated list of allowed origins |
| RATE_LIMIT_MAX / WINDOW_MS | 100 / 60000 | Requests per window per IP, backed by Redis |
| WEBHOOK_RETRY_MAX_ATTEMPTS / BACKOFF_MS | 3 / 5000 | Read by the delivery path. A failed attempt is retried with exponential backoff (BACKOFF_MS × 2^(attempt−1)) until the attempt cap is spent, then the delivery is recorded as dead_letter. The cap is checked when the dispatcher claims a row, so lowering it also retires rows already over the new limit. What feeds the path is a shorter list than the mechanics suggest — one event type today, connection.status.changed, though it fires from five paths; see Webhooks API |
| WEBHOOK_DELIVERY_TIMEOUT_MS | 10000 | Wall-clock ceiling on one attempt, enforced with an AbortSignal at the fetch; a receiver that has not returned response headers by then has that attempt aborted and recorded as failed. Not a budget for the whole delivery: on the defaults a delivery can take 3 × 10 s plus the backoffs (45 s), which POST /webhooks/:id/test awaits |
| WEBHOOK_DISPATCH_INTERVAL_MS | 5000 | How often the in-process dispatcher claims due rows from the webhook_deliveries outbox and makes one attempt per row. 0 disables the loop entirely — no deliveries and no retention purge; only the synchronous test endpoint still sends. Unset is not 0: unset means 5000. A negative value is refused at boot. GET /readyz reports the loop's last successful pass without gating readiness on it |
| WEBHOOK_DISPATCH_BATCH_SIZE / CONCURRENCY | 8 / 4 | Rows one pass claims, and how many of them are in flight at once. At most one row per webhook configuration per pass, so a stuck receiver cannot own the pass. A pass that claims a full batch with rows still due logs at warn — that line is the signal to raise these |
| WEBHOOK_CLAIM_LEASE_MS | 30000 | How long a claimed row is held out of every other pass. Boot refuses a lease that does not outlast the worst-case pass, TIMEOUT_MS × ⌈BATCH_SIZE ÷ CONCURRENCY⌉ (20 s on the defaults), and prints the arithmetic. It is also how long a row stays stuck after an unclean kill before any pass retries it, so a generous lease is a slow recovery |
| WEBHOOK_DELIVERY_RETENTION_DAYS | 90 | Enforced. Deletes delivered and dead_letter rows older than the window; pending rows are never purged. Runs on the dispatch loop's cadence, at most once an hour. 0 disables the purge; a negative or non-finite value is refused at boot. A purged row cannot be redelivered, so this is also the horizon on POST /webhooks/:id/deliveries/:deliveryId/redeliver |
| OBSERVABILITY_EVENT_RETENTION_DAYS | 90 | Enforced. Sets both Keycloak realm eventsExpiration and the Postgres purge of observability_events (same window via observabilityEventRetentionSeconds()). Purge runs once per login-events poll cycle. Does not prune AuditLog rows. |
| OBSERVABILITY_LOGIN_EVENTS_POLL_INTERVAL_MS | 60000 | How often the in-process poller reads Keycloak's own login events into observability_events. Each account's realm is polled separately, with its own cursor and its own failure isolation. 0 disables the in-process timer — set that when you run src/scripts/poll-login-events.ts from an external scheduler instead. Do not run both: there is no lock and no unique constraint, so events are double-counted. |
| REALM_CO_RESIDENCY_SCAN_INTERVAL_MS | 300000 | How often the server re-asks whether any ordinary Keycloak realm holds more than one account. The check also runs once at boot; 0 leaves only the boot run. It recurs because Account.keycloakRealm is written at runtime, so a co-residency created after boot would otherwise stay invisible until the next deploy. An audit row is written per detection, not per pass, so a standing finding does not flood the log. |
For the complete list with every default, see .env.example in the server repo. For how these map onto a Kubernetes deployment, see Helm Chart Reference.