Skip to main content

Configuration & Environment Variables

Central reference for configuring the Heimdall platform: the Rust API (and bots) load layered TOML files with environment-variable overrides via the heimdall-config crate, while the Next.js apps (backend, id, policies) are configured purely through .env files.

Config Resolution Order (API)

The API uses heimdall-config (crates/heimdall-config/src/lib.rs). Configuration is built from four sources, where later sources override earlier ones:

  1. config/default.toml — base defaults (required, load fails if missing)
  2. config/{APP_ENV}.toml — run-mode overrides, e.g. production.toml (optional)
  3. config/local.toml — local developer overrides, git-ignored (optional)
  4. HEIMDALL__* environment variables — runtime overrides (highest priority)

The run mode comes from the APP_ENV environment variable and defaults to "development" when unset. The API config lives in platform/api/config/ (default.toml, staging.toml, production.toml, local.toml).

// Settings::load() in heimdall-config
crate::load("APP_ENV", "HEIMDALL")

Environment-variable override convention

Verified from heimdall-config:

  • Prefix: HEIMDALL
  • Prefix separator: __ (double underscore)
  • Nesting separator: __ (double underscore)

So a TOML key maps to an env var by uppercasing the path and joining sections with __:

TOMLEnvironment variable
database.urlHEIMDALL__DATABASE__URL
server.portHEIMDALL__SERVER__PORT
email.smtp.hostHEIMDALL__EMAIL__SMTP__HOST
storage.upload_limits.images.max_size_mbHEIMDALL__STORAGE__UPLOAD_LIMITS__IMAGES__MAX_SIZE_MB

List-valued keys are parsed as comma-separated strings. The following keys are registered for list parsing (others stay scalar):

  • cors.allowed_origins
  • email.supported_locales
  • storage.upload_limits.images.allowed_types
  • storage.upload_limits.documents.allowed_types
  • storage.upload_limits.videos.allowed_types
  • storage.upload_limits.general.allowed_types

Example: HEIMDALL__CORS__ALLOWED_ORIGINS="https://a.com,https://b.com".

Code defaults vs. shipped default.toml

The Default column below is the compiled-in default from the Rust struct (#[serde(default = ...)]). The shipped platform/api/config/default.toml sometimes sets a different value (e.g. GraphQL path is "/gql" in default.toml but "/graphql" is the struct default). Where a field has no struct default it is required and must be supplied by default.toml (which is always loaded).


API Configuration Reference

All structs live in crates/heimdall-config/src/common.rs (top-level Settings).

[server]

KeyTypeDefaultDescriptionEnv override
hoststringrequired (127.0.0.1)Bind addressHEIMDALL__SERVER__HOST
portu16required (3000)Bind portHEIMDALL__SERVER__PORT
workersusize?none (4)Actix worker threadsHEIMDALL__SERVER__WORKERS
keep_aliveu6475Keep-alive timeout (seconds)HEIMDALL__SERVER__KEEP_ALIVE
public_urlstringhttp://localhost:3000Public base URL for OAuth redirects / external linksHEIMDALL__SERVER__PUBLIC_URL

[database] (PostgreSQL)

KeyTypeDefaultDescriptionEnv override
urlstringrequiredPostgreSQL connection URLHEIMDALL__DATABASE__URL
max_connectionsu32required (10)Max pool connectionsHEIMDALL__DATABASE__MAX_CONNECTIONS
min_connectionsu32required (2)Min idle connectionsHEIMDALL__DATABASE__MIN_CONNECTIONS
connect_timeoutu64required (30)Connect timeout (seconds)HEIMDALL__DATABASE__CONNECT_TIMEOUT
idle_timeoutu64required (600)Idle timeout (seconds)HEIMDALL__DATABASE__IDLE_TIMEOUT
max_lifetimeu641800Max connection lifetime (seconds)HEIMDALL__DATABASE__MAX_LIFETIME

[timescale] (TimescaleDB — GPS & audit time-series)

KeyTypeDefaultDescriptionEnv override
urlstringrequiredTimescaleDB connection URL (default port 5433)HEIMDALL__TIMESCALE__URL
max_connectionsu325Max pool connectionsHEIMDALL__TIMESCALE__MAX_CONNECTIONS
min_connectionsu321Min idle connectionsHEIMDALL__TIMESCALE__MIN_CONNECTIONS
connect_timeoutu6430Connect timeout (seconds)HEIMDALL__TIMESCALE__CONNECT_TIMEOUT
idle_timeoutu64600Idle timeout (seconds)HEIMDALL__TIMESCALE__IDLE_TIMEOUT
max_lifetimeu641800Max connection lifetime (seconds)HEIMDALL__TIMESCALE__MAX_LIFETIME
compression_after_hoursu32168Compress chunks older than N hours (7 days)HEIMDALL__TIMESCALE__COMPRESSION_AFTER_HOURS

[redis]

KeyTypeDefaultDescriptionEnv override
urlstringrequiredRedis connection URLHEIMDALL__REDIS__URL
pool_sizeusizerequired (10)Max pool connectionsHEIMDALL__REDIS__POOL_SIZE
timeoutu645Connection timeout (seconds)HEIMDALL__REDIS__TIMEOUT

[graphql]

KeyTypeDefaultDescriptionEnv override
playgroundbooltrueServe the GraphiQL playground UI at /v1/graphiql. Implies schema_sdl (GraphiQL fetches /v1/schema).HEIMDALL__GRAPHQL__PLAYGROUND
schema_sdlbooltrueServe the GraphQL SDL at /v1/schema (REST counterpart of [docs].openapi_json). Also served when playground = true.HEIMDALL__GRAPHQL__SCHEMA_SDL
pathstring/graphql (toml: /gql)GraphQL endpoint pathHEIMDALL__GRAPHQL__PATH
max_depthu3210Max query depthHEIMDALL__GRAPHQL__MAX_DEPTH
max_complexityu32100Max query complexityHEIMDALL__GRAPHQL__MAX_COMPLEXITY

[docs] (REST API documentation surface)

Config-gated, environment-independent toggles for the REST docs endpoints. Both are unauthenticated when enabled. Shipped production.toml and staging.toml turn swagger_ui and openapi_json off; default.toml (dev) leaves them on.

KeyTypeDefaultDescriptionEnv override
swagger_uibooltrueServe the interactive Swagger UI at /v1/swagger-ui/. Implies openapi_json (the UI needs a fetchable spec).HEIMDALL__DOCS__SWAGGER_UI
openapi_jsonbooltrueServe the standalone OpenAPI 3.1 JSON spec at /v1/openapi.json. Also served when swagger_ui = true.HEIMDALL__DOCS__OPENAPI_JSON

When a surface is disabled its endpoint returns 404 Not Found. The gating is config-driven (not tied to [app].environment): the handlers read the loaded config, and requests with no config resolve fail-closed to 404.

[websocket]

KeyTypeDefaultDescriptionEnv override
pathstring/wsWebSocket endpoint pathHEIMDALL__WEBSOCKET__PATH
heartbeat_intervalu645Heartbeat interval (seconds)HEIMDALL__WEBSOCKET__HEARTBEAT_INTERVAL
client_timeoutu6410Client inactivity timeout (seconds)HEIMDALL__WEBSOCKET__CLIENT_TIMEOUT
max_message_sizeusize65536Max message size (bytes, 64 KB)HEIMDALL__WEBSOCKET__MAX_MESSAGE_SIZE
max_frame_sizeusize65536Max frame size (bytes, 64 KB); hard codec limit, must be ≥ max_message_sizeHEIMDALL__WEBSOCKET__MAX_FRAME_SIZE
max_channels_per_sessionusize100Max channels per WS sessionHEIMDALL__WEBSOCKET__MAX_CHANNELS_PER_SESSION
ticket_ttl_secsu6430Lifetime (seconds) of a single-use WS handshake ticket minted via POST /v1/ws/ticket / mintWsTicket (Ticket-Era auth)HEIMDALL__WEBSOCKET__TICKET_TTL_SECS
max_connection_lifetime_secsu643600Max lifetime (seconds) of a single WS connection; the server stops the socket afterwards and the client re-handshakes with a fresh ticketHEIMDALL__WEBSOCKET__MAX_CONNECTION_LIFETIME_SECS
max_connections_per_ipu3220Max concurrent WS connections per client IP for authenticated sessionsHEIMDALL__WEBSOCKET__MAX_CONNECTIONS_PER_IP
max_anon_connections_per_ipu325Max concurrent WS connections per client IP for anonymous (unauthenticated) sessions — smaller budget than authenticated clientsHEIMDALL__WEBSOCKET__MAX_ANON_CONNECTIONS_PER_IP

Ticket auth: browsers mint a short-lived single-use ticket over their own bearer credential (POST /v1/ws/ticket or GraphQL mintWsTicket) and carry it as a Sec-WebSocket-Protocol entry on the /v1/ws upgrade — the raw credential never appears in the WS URL or logs. See the WebSocket API.

[auth]

KeyTypeDefaultDescriptionEnv override
jwt_secretstringrequired (empty)JWT signing secret — must be overriddenHEIMDALL__AUTH__JWT_SECRET
jwt_expirationi6486400Access token TTL (seconds, 24h)HEIMDALL__AUTH__JWT_EXPIRATION
refresh_token_expirationi642592000Refresh token TTL (seconds, 30d)HEIMDALL__AUTH__REFRESH_TOKEN_EXPIRATION
totp_encryption_keystring""Base64 AES-256 key for TOTP secrets (openssl rand -base64 32)HEIMDALL__AUTH__TOTP_ENCRYPTION_KEY

[cors]

KeyTypeDefaultDescriptionEnv override
allowed_originsstring[]requiredAllowed origins (comma-separated for env)HEIMDALL__CORS__ALLOWED_ORIGINS
allow_credentialsbooltrueAllow credentials in CORSHEIMDALL__CORS__ALLOW_CREDENTIALS
max_ageu643600Preflight cache max-age (seconds)HEIMDALL__CORS__MAX_AGE

allowed_origins must include every webapp origin that performs a browser request against the API. In particular the WebSocket ticket mint POST /v1/ws/ticket is a browser fetch from the webapps, so those origins must be allowed or the mint (and thus the WS handshake) is CORS-blocked. production.toml ships the prod origins; staging must supply its origins via HEIMDALL__CORS__ALLOWED_ORIGINS (staging inherits default.toml's localhost origins otherwise).

[rate_limiting]

Auth-context-aware rate limiting (heimdall-rest::rate_limit, RateLimitMiddleware). Fixed-window Redis counter, keyed per identity/tier — runs after AuthMiddleware so the caller's AuthContext (and therefore tier) is known.

KeyTypeDefaultDescriptionEnv override
window_secsu6460Window length (seconds); applies to all tiersHEIMDALL__RATE_LIMITING__WINDOW_SECS
apikey_max_requestsu641200Max requests/window for non-system API keysHEIMDALL__RATE_LIMITING__APIKEY_MAX_REQUESTS
user_max_requestsu64600Max requests/window for authenticated usersHEIMDALL__RATE_LIMITING__USER_MAX_REQUESTS
anonymous_max_requestsu64120Max requests/window for anonymous (unauthenticated) requestsHEIMDALL__RATE_LIMITING__ANONYMOUS_MAX_REQUESTS
internal_tokenstring?"" (disabled)Shared secret; a matching X-Internal-Token header skips rate limiting, anonymous tier only. Empty = bypass disabled (fail-safe). Never commit a real value — set per-environment via env (openssl rand -hex 32)HEIMDALL__RATE_LIMITING__INTERNAL_TOKEN

Tiers (heimdall_db::models::RateTier, resolved from AuthContext)

TierWhoLimit
UnlimitedSystem API key (is_system = true) — hardcoded, short-circuited before the Redis call
ApiKeyNon-system API keyapikey_max_requests
UserLogged-in user (session/JWT)user_max_requests
AnonymousNo auth contextanonymous_max_requests

Redis key: heimdall:ratelimit:inbound:{apikey:{id}\|user:{uid}\|anon:{ip}}.

Headers: rate-limited responses carry X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (excluded: unlimited/system keys, exempt paths, fail-open). Exceeding the limit returns 429 Too Many Requests plus Retry-After (seconds until the window resets).

Exempt paths (never limited): /health, /v1/health, /v1/ws, /v1/openapi.json, /v1/schema, and everything under /v1/swagger-ui. OAuth endpoints (/v1/oauth/*) are limited.

Fail-open: if Redis is unreachable, the middleware logs a warning and allows the request through rather than taking the API down.

[logging]

KeyTypeDefaultDescriptionEnv override
levelstringrequired (debug)Log level: trace/debug/info/warn/errorHEIMDALL__LOGGING__LEVEL
formatstringrequired (pretty)Log format: json or prettyHEIMDALL__LOGGING__FORMAT

[app]

KeyTypeDefaultDescriptionEnv override
environmentstringrequired (development)App environment: development/staging/productionHEIMDALL__APP__ENVIRONMENT
debugbooltrueDebug modeHEIMDALL__APP__DEBUG
namestringrequired (Heimdall API)Application nameHEIMDALL__APP__NAME

[apps]

Frontend URLs used for OAuth client redirect URIs on first launch.

KeyTypeDefaultDescriptionEnv override
backend_urlstringhttp://localhost:3001Backend/console app URLHEIMDALL__APPS__BACKEND_URL
id_urlstringhttp://localhost:3002ID (login) app URLHEIMDALL__APPS__ID_URL
policies_urlstringhttp://localhost:3004Policies app URLHEIMDALL__APPS__POLICIES_URL

[bots]

Optional bot health-check endpoints.

KeyTypeDefaultDescriptionEnv override
discord_bot_urlstring?noneDiscord bot health URL (e.g. http://localhost:3006/health)HEIMDALL__BOTS__DISCORD_BOT_URL
twitch_bot_urlstring?noneTwitch bot health URL (e.g. http://localhost:3007/health)HEIMDALL__BOTS__TWITCH_BOT_URL

[scheduler]

The whole section is optional (Option<SchedulerConfig>).

KeyTypeDefaultDescriptionEnv override
enabledbooltrueEnable background schedulerHEIMDALL__SCHEDULER__ENABLED
deletion_cronstring?0 */15 * * * *Account-deletion job cron (every 15 min)HEIMDALL__SCHEDULER__DELETION_CRON
integration_refresh_cronstring?0 */5 * * * *Integration token-refresh cron (every 5 min)HEIMDALL__SCHEDULER__INTEGRATION_REFRESH_CRON
integration_refresh_buffer_minutesi64?15Refresh this many minutes before token expiryHEIMDALL__SCHEDULER__INTEGRATION_REFRESH_BUFFER_MINUTES
channel_stats_refresh_cronstring?0 0 */6 * * *Channel-stats refresh cron (every 6h)HEIMDALL__SCHEDULER__CHANNEL_STATS_REFRESH_CRON
channel_stats_stale_minutesi64?360Minutes until channel stats are stale (6h)HEIMDALL__SCHEDULER__CHANNEL_STATS_STALE_MINUTES

Cron format is 6-field: sec min hour day month day-of-week. The defaults listed are the documented defaults; default.toml ships deletion_cron = "0 */1 * * * *".

[email]

KeyTypeDefaultDescriptionEnv override
enabledboolfalseEnable email sendingHEIMDALL__EMAIL__ENABLED
providerstringconsolesendgrid, smtp, or consoleHEIMDALL__EMAIL__PROVIDER
from_emailstringnoreply@elcapitano.comSender addressHEIMDALL__EMAIL__FROM_EMAIL
from_namestringelcapitano IdentitySender display nameHEIMDALL__EMAIL__FROM_NAME
token_expiry_hoursu3224Verification-link TTL (hours)HEIMDALL__EMAIL__TOKEN_EXPIRY_HOURS
default_localestringenFallback email localeHEIMDALL__EMAIL__DEFAULT_LOCALE
supported_localesstring[]["en","de"]Supported email locales (comma-separated for env)HEIMDALL__EMAIL__SUPPORTED_LOCALES

[email.sendgrid]

KeyTypeDefaultDescriptionEnv override
api_keystring""SendGrid API keyHEIMDALL__EMAIL__SENDGRID__API_KEY

[email.smtp]

KeyTypeDefaultDescriptionEnv override
hoststringlocalhostSMTP hostHEIMDALL__EMAIL__SMTP__HOST
portu16587SMTP port (587 STARTTLS / 465 SSL / 25 plain)HEIMDALL__EMAIL__SMTP__PORT
usernamestring""SMTP usernameHEIMDALL__EMAIL__SMTP__USERNAME
passwordstring""SMTP passwordHEIMDALL__EMAIL__SMTP__PASSWORD
tlsbooltrueUse TLS/STARTTLSHEIMDALL__EMAIL__SMTP__TLS
timeoutu6430Connection timeout (seconds)HEIMDALL__EMAIL__SMTP__TIMEOUT

[turnstile]

KeyTypeDefaultDescriptionEnv override
enabledboolfalseEnable Cloudflare Turnstile verificationHEIMDALL__TURNSTILE__ENABLED
secret_keystring""Turnstile server-side secret keyHEIMDALL__TURNSTILE__SECRET_KEY

[sentry]

KeyTypeDefaultDescriptionEnv override
enabledboolfalseEnable Sentry error trackingHEIMDALL__SENTRY__ENABLED
dsnstring""Sentry DSNHEIMDALL__SENTRY__DSN
environmentstringdevelopmentSentry environment nameHEIMDALL__SENTRY__ENVIRONMENT
traces_sample_ratef321.0Transaction sample rate (0.0–1.0)HEIMDALL__SENTRY__TRACES_SAMPLE_RATE
sample_ratef321.0Error-event sample rate (0.0–1.0)HEIMDALL__SENTRY__SAMPLE_RATE
debugboolfalseSentry SDK debug modeHEIMDALL__SENTRY__DEBUG
attach_stacktracebooltrueAttach stack traces to messagesHEIMDALL__SENTRY__ATTACH_STACKTRACE
send_default_piiboolfalseSend PII in error reportsHEIMDALL__SENTRY__SEND_DEFAULT_PII
max_breadcrumbsusize100Max breadcrumbs capturedHEIMDALL__SENTRY__MAX_BREADCRUMBS

[geoip]

MaxMind GeoLite2 IP-to-location lookups used for audit enrichment.

KeyTypeDefaultDescriptionEnv override
enabledboolfalseMaster switch; when off no DB is loaded and location fields stay emptyHEIMDALL__GEOIP__ENABLED
database_pathstring?nonePath to GeoLite2 City .mmdbHEIMDALL__GEOIP__DATABASE_PATH
account_idstring?noneMaxMind account ID (Privacy Exclusions API)HEIMDALL__GEOIP__ACCOUNT_ID
license_keystring?noneMaxMind license key (auto-update / Privacy Exclusions)HEIMDALL__GEOIP__LICENSE_KEY
auto_updateboolfalseAuto-update DB on startup (needs license key)HEIMDALL__GEOIP__AUTO_UPDATE
update_interval_daysu327DB update interval (days)HEIMDALL__GEOIP__UPDATE_INTERVAL_DAYS
privacy_exclusions_enabledboolfalseEnable Privacy Exclusions APIHEIMDALL__GEOIP__PRIVACY_EXCLUSIONS_ENABLED
privacy_exclusions_refresh_hoursu3224Refresh interval for exclusions (hours)HEIMDALL__GEOIP__PRIVACY_EXCLUSIONS_REFRESH_HOURS
privacy_exclusions_cache_pathstringdata/geoip-privacy-exclusions.jsonLocal cache file pathHEIMDALL__GEOIP__PRIVACY_EXCLUSIONS_CACHE_PATH

[integrations]

Streaming-platform OAuth (Twitch, YouTube, Kick, Trovo) — separate from user auth.

KeyTypeDefaultDescriptionEnv override
token_encryption_keystring""AES-256-GCM key for stored OAuth tokens (openssl rand -base64 32)HEIMDALL__INTEGRATIONS__TOKEN_ENCRYPTION_KEY
internal_service_keystring""Bot-to-API auth key (openssl rand -hex 32)HEIMDALL__INTEGRATIONS__INTERNAL_SERVICE_KEY

Each platform sub-section ([integrations.twitch], [integrations.youtube], [integrations.kick], [integrations.trovo]) has the same two keys:

KeyTypeDefaultDescriptionEnv override (example: twitch)
client_idstring""OAuth client IDHEIMDALL__INTEGRATIONS__TWITCH__CLIENT_ID
client_secretstring""OAuth client secretHEIMDALL__INTEGRATIONS__TWITCH__CLIENT_SECRET

Swap TWITCH for YOUTUBE, KICK, or TROVO for the other platforms.

[storage] (S3-compatible / local filesystem)

KeyTypeDefaultDescriptionEnv override
enabledboolfalseEnable storage serviceHEIMDALL__STORAGE__ENABLED
backendstring"s3" (toml: "local")Storage backend: "s3" (S3/MinIO/R2/Spaces) or "local" (filesystem)HEIMDALL__STORAGE__BACKEND
local_pathstring?none (toml: ./uploads)Local filesystem base path; only used when backend = "local"HEIMDALL__STORAGE__LOCAL_PATH
endpointstringhttp://localhost:9000S3 endpoint URLHEIMDALL__STORAGE__ENDPOINT
regionstringus-east-1 (toml: auto)S3 regionHEIMDALL__STORAGE__REGION
access_keystring""S3 access key IDHEIMDALL__STORAGE__ACCESS_KEY
secret_keystring""S3 secret access keyHEIMDALL__STORAGE__SECRET_KEY
bucketstringheimdallDefault bucketHEIMDALL__STORAGE__BUCKET
path_styleboolfalseUse path-style URLs (required for MinIO)HEIMDALL__STORAGE__PATH_STYLE
public_urlstring?nonePublic CDN URL if different from endpointHEIMDALL__STORAGE__PUBLIC_URL
presigned_expiry_secondsu323600Presigned URL TTL (seconds)HEIMDALL__STORAGE__PRESIGNED_EXPIRY_SECONDS

On the local backend, presigned-download returns the direct GET /v1/storage/download/{key} URL (not a signed URL); presigned-upload and multipart uploads are not supported (StorageError::Unsupported). See Storage API for the full operation matrix.

[storage.upload_limits.*]

Four categories — images, documents, videos, general — each with the same keys:

KeyTypeDefault (images / documents / videos / general)Description
max_size_mbu645 / 25 / 500 / 100Max upload size (MB)
allowed_typesstring[]jpeg,png,webp,gif / pdf,plain,json / mp4,webm,quicktime,x-msvideo,x-matroska / [] (all)Allowed MIME types

Env overrides follow the nested rule, e.g. HEIMDALL__STORAGE__UPLOAD_LIMITS__IMAGES__MAX_SIZE_MB and the comma-separated HEIMDALL__STORAGE__UPLOAD_LIMITS__IMAGES__ALLOWED_TYPES.

[pegelonline] (WSV water-level monitoring)

KeyTypeDefaultDescriptionEnv override
enabledboolfalseEnable Pegelonline pollingHEIMDALL__PEGELONLINE__ENABLED
base_urlstringhttps://pegelonline.wsv.de/webservices/rest-api/v2Pegelonline REST API baseHEIMDALL__PEGELONLINE__BASE_URL
radius_kmf6430.0Search radius around GPS position (km)HEIMDALL__PEGELONLINE__RADIUS_KM
stationsstring[][]Station whitelist (exact API shortnames); empty = radius-basedHEIMDALL__PEGELONLINE__STATIONS

[ais] (vessel tracking)

KeyTypeDefaultDescriptionEnv override
enabledboolfalseEnable AIS trackingHEIMDALL__AIS__ENABLED
api_keystring""AIS data-provider API keyHEIMDALL__AIS__API_KEY
radius_kmf6410.0Search radius around GPS position (km)HEIMDALL__AIS__RADIUS_KM
ignore_mmsiu32[][]MMSI numbers to exclude (e.g. own vessel)HEIMDALL__AIS__IGNORE_MMSI

Next.js App Environment Variables

The web apps (platform/backend, platform/id, platform/policies) are configured entirely via .env (see each app's .env.example). NEXT_PUBLIC_* vars are exposed to the browser; all others are server-only.

Common to all three apps

VariableExampleDescription
NEXTAUTH_URLhttp://localhost:3001NextAuth base URL (per app port)
NEXTAUTH_SECRETNextAuth session secret
HEIMDALL_CLIENT_IDheimdall-backend-clientOAuth client ID for this app
HEIMDALL_CLIENT_SECRETOAuth client secret
NEXT_PUBLIC_API_URLhttp://localhost:3000Heimdall API base URL
NEXT_PUBLIC_GRAPHQL_URLhttp://localhost:3000/v1/gqlGraphQL endpoint
NEXT_PUBLIC_WS_URLws://localhost:3000/v1/wsWebSocket endpoint
NEXT_PUBLIC_ID_URLhttp://localhost:3002ID app URL (app switcher)
NEXT_PUBLIC_POLICIES_URLhttp://localhost:3004Policies app URL
NEXT_PUBLIC_CONSOLE_URLhttp://localhost:3001Console/backend app URL
NEXT_PUBLIC_DOCS_URLhttp://localhost:3003Docs app URL
NEXT_PUBLIC_MAIN_URLhttp://localhost:3005Main site URL
SYSTEM_API_KEYServer-only system API key (*:*) for NextAuth adapter calls
SOURCE_SERVICEbackend / id / policiesIdentifies the app in audit logs (X-Source-Service)
NEXT_PUBLIC_TURNSTILE_SITE_KEYCloudflare Turnstile site key (empty disables)
NEXT_PUBLIC_GA_MEASUREMENT_IDGoogle Analytics ID (statistics-consent gated)
NEXT_PUBLIC_FB_PIXEL_IDFacebook Pixel ID (optional)
NEXT_PUBLIC_HOTJAR_SITE_IDHotjar site ID (optional)
NEXT_PUBLIC_PLAUSIBLE_DOMAINPlausible domain (optional)
NEXT_PUBLIC_SENTRY_DSNSentry DSN (functional-consent gated)
SENTRY_ORGSentry org (build/source maps)
SENTRY_PROJECTSentry project
SENTRY_AUTH_TOKENSentry auth token
NEXT_PUBLIC_SENTRY_DEBUGfalseEnable Sentry in development

App-specific defaults

AppNEXTAUTH_URL portHEIMDALL_CLIENT_IDSOURCE_SERVICE
backend3001heimdall-backend-clientbackend
id3002heimdall-id-clientid
policies3004heimdall-policies-clientpolicies

Backend-only

VariableExampleDescription
NEXT_PUBLIC_HEIMDALL_CLIENT_IDheimdall-backend-clientPublic client ID for WebSocket consent-revocation detection

Policies-only

VariableExampleDescription
NEXT_PUBLIC_HEIMDALL_CLIENT_IDheimdall-policies-clientPublic client ID for WebSocket consent-revocation detection

ID-only — user-auth OAuth providers

The ID app brokers social login. Each provider needs a client ID/secret (Steam also needs an API key):

ProviderVariables
TwitchTWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET
GoogleGOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
DiscordDISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
GitHubGITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
KickKICK_CLIENT_ID, KICK_CLIENT_SECRET
SteamSTEAM_CLIENT_ID, STEAM_CLIENT_SECRET, STEAM_API_KEY
TrovoTROVO_CLIENT_ID, TROVO_CLIENT_SECRET

These ID-app provider OAuth credentials are separate from the API's [integrations.*] platform OAuth, which connects channel/bot features.


Webapp Runtime Configuration (Next.js apps)

The Next.js apps (platform/backend, platform/id, platform/policies) resolve their service URLs at request time, not at build time. This lets a single Docker image be built once and re-pointed per environment via runtime env vars — no rebuild required.

Single source: src/lib/config.ts

Each app has a src/lib/config.ts that is the single source of env resolution. It exposes getter functions; each getter resolves its value at call time with this priority:

  1. HEIMDALL_* — runtime override, set at container start, server-only (wins)
  2. NEXT_PUBLIC_* — build-time fallback, baked into the bundle (client fallback)
  3. localhost default — local dev
HEIMDALL_* → NEXT_PUBLIC_* → localhost default
(runtime) (build-time) (dev)

The server evaluates HEIMDALL_* at request time (runtime config), while the browser has no access to server-only HEIMDALL_* vars and therefore falls back to the build-time NEXT_PUBLIC_* value. This split removes any SSR requirement for client code.

Getters (verified from platform/id/src/lib/config.ts)

GetterResolvesExample value
getApiUrl()API base host (no /v1)http://localhost:3000
getGraphqlUrl()GraphQL endpointhttp://localhost:3000/v1/gql
getWsUrl()WebSocket endpointws://localhost:3000/v1/ws
getIdUrl()ID app URLhttp://localhost:3002
getConsoleUrl()Console/backend app URLhttp://localhost:3001
getDocsUrl()Docs app URLhttp://localhost:3003
getPoliciesUrl()Policies app URLhttp://localhost:3004
getMainUrl()Main site URLhttp://localhost:3005
getSystemApiKey()System API key (server-only, no public fallback → "" on client)
getSourceService()App identifier for audit (SOURCE_SERVICE)id
getCookieDomain()Shared cookie domain (HEIMDALL_COOKIE_DOMAINCOOKIE_DOMAIN)none
getSelfUrl()This app's own URL (NEXTAUTH_URL)http://localhost:3002

getApiUrl() returns the base host only — the app appends paths (/v1/...). Do not point HEIMDALL_API_URL at a /v1 path.

Config-first @elcto/api

The shared @elcto/api library is env-agnostic: it never reads process.env itself. Every transport (apiRequest, graphqlRequest, createWebSocket) and route helper takes an ApiClientConfig as its first argument:

interface ApiClientConfig {
baseUrl: string; // e.g. "http://localhost:3000" (NO trailing /v1)
wsUrl?: string; // e.g. "ws://localhost:3000/v1/ws" (derived from baseUrl if absent)
systemApiKey?: string; // service-to-service / SSR calls
internalToken?: string; // sent as X-Internal-Token; bypasses rate limiting (anonymous tier only)
sourceService?: string; // sent as X-Source-Service on every request; audit source attribution
}

Each app builds this from config.ts via getApiConfig() in src/lib/api/index.ts and passes it to every API call:

// platform/id/src/lib/api/index.ts
export function getApiConfig(): ApiClientConfig {
return {
baseUrl: getApiUrl(),
wsUrl: getWsUrl(),
systemApiKey: getSystemApiKey() || undefined,
internalToken: getInternalToken() || undefined,
sourceService: getSourceService(),
};
}

On the client, getSystemApiKey() returns "" (no public fallback) → undefined, so the system key never reaches the browser. sourceService has no such restriction — it is not a secret — so it is sent from both server and client contexts.

The GraphQL and REST transports (shared/api/src/clients/graphql.ts, rest.ts) send options.sourceService ?? config.sourceService as the X-Source-Service header on every request, so as long as an app builds its config via getApiConfig() the header is present by default (a per-call sourceService option can still override it). This is what lets the Rust API attribute the login audit event (emitted at OAuth token exchange) to the calling webapp instead of the generic "api" source fallback — see Source & device attribution from webapps. See the API Library docs for the transport APIs.

Runtime override vars (HEIMDALL_*)

All optional and server-only. Each app's .env.example documents the same set. When set, each takes precedence over the matching NEXT_PUBLIC_* build-time value.

VariableOverridesMaps to getter
HEIMDALL_API_URLNEXT_PUBLIC_API_URLgetApiUrl()
HEIMDALL_GRAPHQL_URLNEXT_PUBLIC_GRAPHQL_URLgetGraphqlUrl()
HEIMDALL_WS_URLNEXT_PUBLIC_WS_URLgetWsUrl()
HEIMDALL_ID_URLNEXT_PUBLIC_ID_URLgetIdUrl()
HEIMDALL_CONSOLE_URLNEXT_PUBLIC_CONSOLE_URLgetConsoleUrl()
HEIMDALL_DOCS_URLNEXT_PUBLIC_DOCS_URLgetDocsUrl()
HEIMDALL_POLICIES_URLNEXT_PUBLIC_POLICIES_URLgetPoliciesUrl()
HEIMDALL_MAIN_URLNEXT_PUBLIC_MAIN_URLgetMainUrl()
HEIMDALL_COOKIE_DOMAINCOOKIE_DOMAINgetCookieDomain()
HEIMDALL_INTERNAL_TOKEN(no public fallback, server-only)getInternalToken() — must match the API's HEIMDALL__RATE_LIMITING__INTERNAL_TOKEN

Note the distinct prefixes: the API (Rust) uses HEIMDALL__* (double underscore, nested config keys); the webapp runtime overrides use HEIMDALL_* (single underscore, flat URL vars). They are different mechanisms.


Bot Configuration

The bots use the same layered-TOML approach but with their own env prefix and a single underscore (_) separator (not __). Each bot has config/default.toml and an optional config/local.toml.

BotEnv prefixRun-mode envSeparator
DiscordDISCORD_BOT_DISCORD_BOT_RUN_MODE_
TwitchTWITCH_BOT__
YouTubeYOUTUBE_BOT__

The Discord bot (platform/discord_bot/src/config/settings.rs) loads config/default.tomlconfig/{RUN_MODE}.tomlDISCORD_BOT_* env vars. Because the separator is a single _, a nested key like bot.token maps to DISCORD_BOT_BOT_TOKEN:

TOML keyEnv var
bot.tokenDISCORD_BOT_BOT_TOKEN
api.keyDISCORD_BOT_API_KEY
sentry.dsnDISCORD_BOT_SENTRY_DSN

Discord bot config sections (default.toml): environment, [bot] (prefix, default_locale, token), [api] (graphql_endpoint, rest_endpoint, websocket_endpoint, key, bind_address, bind_port), [apps], [sentry] (dsn, traces_sample_rate), [logging] (level).

The Twitch and YouTube bots ship the same TOML layout (referencing TWITCH_BOT_* / YOUTUBE_BOT_* env vars in their config/default.toml) but are currently scaffolds — their main.rs is a stub.


Next Steps