Skip to content

Environment Variables Reference

This page lists the environment variables JAiRouter supports, when each one takes effect, what happens when it is missing, and the real differences between the shipped profiles. Every entry is taken from the bundled configuration and the code, with the location that makes it effective in parentheses. Statements marked "measured" come from real starts of this code base (prod / dev / staging) plus endpoint readings.

Read first: the three channels a variable can travel

Variables do not all take effect the same way. Knowing the channel is what tells you "why did setting it do nothing".

ChannelTriggerTypical variablesConsequence of a mistake
yml placeholderThe file explicitly contains ${VAR:default}JWT_SECRET, DATABASE_URL, GPUSTACK_API_TOKEN, JAIROUTER_*Falls back to the default, silently
Spring relaxed bindingVariable name = property key in upper snake case; works whenever the target property existsSERVER_PORT, SPRING_PROFILES_ACTIVE, JAiROUTER_SECURITY_*, SPRING_DATA_REDIS_HOSTA misspelled name raises nothing — it is simply ignored
Direct process environmentCode calls System.getenv("…")Only JWT_SECRET and INITIAL_ADMIN_PASSWORDThis channel honours neither relaxed binding nor aliases

The difference between the second and third channel is the part most easily missed: JWT_SECRET must carry exactly that name because the startup gate (StartupSecretKeyChecker / StartupSecurityGate) reads the process environment instead of the property tree. Since #674 the four aliases DEV_JWT_SECRET / STAGING_JWT_SECRET / PROD_JWT_SECRET / ADMIN_PASSWORD are gone; setting them now only produces one warn line in the startup log.

Variables you must set (per profile)

ProfileVariableIf missingBasis
prod / productionJWT_SECRET (at least 32 characters, not a historical default)The startup gate throws IllegalStateException and the process exitsStartupSecurityGate.enforce
prod / productionINITIAL_ADMIN_PASSWORD (not ChangeMeOnFirstStartup123456)Same, the process exitsSame
stagingJWT_SECRET (at least 32 characters)Configuration binding fails: JwtConfig.secret violates @Size(min = 32) and the process exits with code 1application-staging.yml:33 → config/auth/jwt.yml:24
dev / no profile at allNoneJWT_SECRET and the administrator password are generated randomly and printed in the startup logconfig/auth/jwt.yml:24, :48

Generate a strong JWT_SECRET with the published image:

docker run --rm sodlinken/jairouter:latest --generate-key

One production pattern that never works: setting only PROD_JWT_SECRET without JWT_SECRET. The gate reads the latter, so such a deployment never starts.

Variables worth setting (depending on the deployment shape)

Data storage

VariableDefaultWhen it becomes mandatory
DATABASE_URLjdbc:h2:file:./data/jairouter;… (config/config-service/core.yml:18)Recommended for prod: no profile overrides the datasource, so the default is an H2 file inside the container. docker-compose*.yml keeps it with a ./data:/app/data volume; a bare docker run or a Kubernetes pod without a volume loses all data with the container
DATABASE_USERNAMEsa (:19)Supply together with DATABASE_URL when pointing at an external database
DATABASE_PASSWORDempty (:20)Same

Redis (only when at least one Redis-backed switch is on)

VariableDefaultNotes
REDIS_HOSTlocalhost (config/security/persistence-base.yml:80)Equivalent: SPRING_DATA_REDIS_HOST
REDIS_PORT6379 (:81)Equivalent: SPRING_DATA_REDIS_PORT
REDIS_PASSWORDempty (:82)Set this in production
REDIS_DATABASE0 (:83)Use it to separate key spaces when environments share one Redis
REDIS_ENABLEDfalse (:114, config/base/monitoring-base.yml:49)Controls only whether the health probe checks Redis, not whether Redis is used

The connection timeout is not an environment variable: spring.data.redis.timeout: 5000ms (persistence-base.yml:84). The image's REDIS_TIMEOUT has no binding (see #677).

Upstream model services

VariableDefaultNotes
GPUSTACK_API_TOKENemptyUsed by the nine Authorization: "Bearer ${GPUSTACK_API_TOKEN:}" entries in config/router/services.yml. When unset the upstream receives an empty Bearer header and rejects the call itself

Multiple replicas

VariableDefaultMeaning
JAIROUTER_REPLICAS0 (config/common/cluster.yml:16)0 = unknown, probe at runtime; 1 = single replica, skip the shared-state check; >1 = multi-replica, enforce it
JAIROUTER_SHARED_STATE_MODEWARN (:38)OFF / WARN / FAIL — silence, warn, or refuse to start
JAIROUTER_SHARED_STATE_FAIL_HEALTHfalse (:47)true reports shared-state gaps through the health endpoint
JAIROUTER_CLUSTER_EVENTSfalse (:29)Real-time event broadcast across replicas; degrades to this replica only, with a warning, when Redis is unavailable
JAIROUTER_RATELIMIT_DISTRIBUTEDfalse (config/router/ratelimit.yml:46)Whether end-user rate-limit counters live in Redis; Redis failures fail open, each call times out after 50 ms (:51)

Which switches a multi-replica deployment must enable, and what each one changes, is covered in Multi-Replica Deployment.

Framework-level switches

These are not yml placeholders; they work through Spring relaxed binding. The image already sets several of them (Dockerfile:55-64):

VariableEquivalent propertyImage default
SPRING_PROFILES_ACTIVEactive profileprod
SERVER_PORTserver.port8080
JAiROUTER_SECURITY_ENABLEDjairouter.security.enabledtrue
JAiROUTER_SECURITY_JWT_ENABLEDjairouter.security.jwt.enabledtrue
JAiROUTER_SECURITY_API_KEY_ENABLEDjairouter.security.api-key.enabledtrue
JAiROUTER_SECURITY_SANITIZATION_REQUEST_ENABLEDjairouter.security.sanitization.request.enabledtrue
JAiROUTER_SECURITY_SANITIZATION_RESPONSE_ENABLEDjairouter.security.sanitization.response.enabledtrue
JAiROUTER_SECURITY_AUDIT_ENABLEDjairouter.security.audit.enabledtrue

Default differences between profiles

The table below shows the same keys across the three profiles. Every difference comes from application-{profile}.yml overriding the base configuration; no environment variable is required to obtain them.

Keydefault (none)devstagingprod
jairouter.security.jwt.secret fallback${random.uuid} (new on every start)Development literal dev-secret-key-for-testing-only-32-characters-minimumEmpty ⇒ JWT_SECRET is requiredEmpty ⇒ JWT_SECRET is required
jairouter.security.jwt.issuerjairouterjairouterjairouter-stagingjairouter-prod
Administrator password fallback${random.uuid}, printedChangeMeOnFirstStartup123456${random.uuid}, printedEmpty ⇒ INITIAL_ADMIN_PASSWORD is required
Built-in test API keynonepresent (test-key-001)nonenone
jairouter.security.jwt.persistence.enabledfalsefalsetrue (application-staging.yml:38-41)false
jairouter.security.jwt.persistence.redis.enabledfalsefalsetruefalse
jairouter.security.audit.log-levelINFODEBUG (records request/response bodies)INFO (30-day retention)WARN (no bodies, 90-day retention)
jairouter.security.sanitization.response.enabledfalsefalsetruetrue
Log level / fileconsoleDEBUGroot INFO, app INFO, Spring WARN → logs/jairouter-staging.logroot WARN, app WARN, Spring ERROR → logs/jairouter-prod.log
management.endpoints.web.exposure.includehealth,info,metrics,prometheus,jairouter-metrics,error-tracking (config/base/monitoring-base.yml:16)* (application-dev.yml:49)Inherits the base value, show-details: when-authorizedhealth,info,prometheus (application-prod.yml:33)

What jwt.persistence.enabled actually switches off

This key is easily read as "turn token persistence off". It does not. What it gates is everything around persistence:

Gated componentConditionVisible consequence when off
JwtTokenLifecycleServiceImpljwt.persistence.enabled=trueNo status-history (statusHistory) writes
JwtTokenExpirationSchedulerSameExpired tokens never transition from ACTIVE to EXPIRED
JwtServiceHealthCheckerSameOne JWT persistence probe missing from health
The two @ConfigurationProperties of JwtPersistenceConfigurationSamestoragePath / memory.* / cleanupThreshold have nothing to bind to (and no consumer today)

Persistence itself is not controlled by it: the component that writes and queries tokens is JwtServiceConfiguration.storeManagerJwtPersistenceService, whose condition only looks at jairouter.security.jwt.persistence.redis.enabled=false (matchIfMissing=true). Measured on prod with the switch false: after two logins, GET /api/auth/jwt/blacklist/stats returns persistedActiveTokens: 2, so tokens are in the store.

Cleanup is an independent switch, jwt.persistence.cleanup.enabled (default true); the shipped effective values are 02:00 daily, 30-day retention (measured: JWT cleanup service initialized with schedule: 0 0 2 * * ?, retention days: 30).

JWT persistence and blacklist persistence are defined in exactly one file: config/security/persistence-base.yml. Since #676config/auth/jwt.yml no longer duplicates that block, so a later import cannot silently override it.

Removed or ineffective names

NameStatus
DEV_JWT_SECRET / STAGING_JWT_SECRET / PROD_JWT_SECRETRemoved in #674; setting them only produces a startup warning
ADMIN_PASSWORDRemoved in #674; the administrator password is INITIAL_ADMIN_PASSWORD only
JWT_ALGORITHM / JWT_EXPIRATION_MINUTES / REDIS_TIMEOUTExported by the image with no binding (#677); the real keys are jairouter.security.jwt.algorithm, jairouter.security.jwt.expiration-minutes, spring.data.redis.timeout
ADMIN_API_KEY / USER_API_KEYNot referenced by the shipped configuration; read only by the yml generated by scripts/migration/migrate-to-security.ps1 (#677)

How to check what is effective

  • Search the startup log for JWT cleanup service initialized with schedule: and The following 1 profile is active: — that confirms the effective profile and cleanup parameters.
  • GET /actuator/health: whether Redis is probed depends on REDIS_ENABLED.
  • GET /api/auth/jwt/cleanup/stats and GET /api/auth/jwt/blacklist/stats need the Jairouter_Token header (JAiRouter's own JWTs travel in that header; Authorization is reserved for downstream services).
  • To enumerate every placeholder and its default: git grep -n '\${' -- src/main/resources.