← Documentation

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

VariableDefaultNotes
NODE_ENV / PORT / HOST / LOG_LEVELdevelopment / 4000 / 0.0.0.0 / infoBasic runtime config
DATABASE_URLrequired, no defaultPostgreSQL connection string
REDIS_URLredis://localhost:6379Rate limiting and caching
PUBLIC_BASE_URLhttp://localhost:4000RealmSSO's own public origin, used to build SP-side URLs handed to customers

Keycloak

VariableDefaultNotes
KEYCLOAK_ADMIN_URLrequired, no defaultServer-to-server admin API URL — often internal cluster DNS
KEYCLOAK_PUBLIC_URLfalls back to KEYCLOAK_ADMIN_URLBrowser-facing origin — must differ whenever the admin URL is internal-only
KEYCLOAK_REALMrealmssoBase realm, and prefix for every per-account realm (<KEYCLOAK_REALM>-<slug>)
KEYCLOAK_AUTH_REALMmasterThe 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 / SECRETrealmsso-admin / optionalService account credentials
KEYCLOAK_ALLOW_REALM_CREATIONtrueMust stay true for account provisioning to work — see Self-Hosting Guide

Auth & sessions

VariableDefaultNotes
JWT_SECRETrequired, min 32 charsSigns vendor dashboard JWTs — generate with openssl rand -hex 64
MAGIC_LINK_SECRET / EXPIRY_MINUTESrequired / 15Signs magic-link tokens and how long they stay valid
REFRESH_TOKEN_EXPIRY_DAYS30Vendor dashboard refresh token lifetime
ENCRYPTION_KEYrequired, min 32-byte hexAES-256-GCM key encrypting client secrets at rest — see Encryption at Rest

Admin Portal & Dashboard

VariableDefaultNotes
ADMIN_PORTAL_TOKEN_EXPIRY_MINUTES30How long a customer Admin Portal access link stays valid
ADMIN_PORTAL_BASE_URLhttp://localhost:4000/adminBase URL portal links are built against
DASHBOARD_BASE_URLhttp://localhost:3000Vendor 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

VariableDefaultNotes
SMTP_HOST / PORT / USER / PASSunset / 587 / unset / unsetLeave unset in development — a JSON transport logs the message instead of sending it
SMTP_FROMplaceholder noreply@ addressSet to an address on a domain you control, or mail fails SPF/DKIM

CORS, rate limiting, webhooks, retention, background jobs

VariableDefaultNotes
CORS_ORIGINShttp://localhost:3000Comma-separated list of allowed origins
RATE_LIMIT_MAX / WINDOW_MS100 / 60000Requests per window per IP, backed by Redis
WEBHOOK_RETRY_MAX_ATTEMPTS / BACKOFF_MS3 / 5000Read 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 today, connection.status.changed, from the connections PATCH route only; see Webhooks API
WEBHOOK_DELIVERY_TIMEOUT_MS10000Wall-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_MS5000How 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 / CONCURRENCY8 / 4Rows 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_MS30000How 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_DAYS90Enforced. 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_DAYS90Enforced. 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_MS60000How 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_MS300000How 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.