Skip to content
ImplementedFor operators, IT, security reviewers

Configuration and environment variables

Last updated Oct 21, 2026

Every setting is an environment variable read once at start-up and checked against a schema: a service that is misconfigured refuses to start and says which variable is wrong. In production and staging a set of extra checks refuses settings that would weaken a control (a development shortcut left on, a limit too loose to be a real one). Secrets are never written to this page; a variable that holds one is marked.

The one-time-code shortcut OTP_DEV_CODE_IN_RESPONSE returns every sign-in code in the response and is refused outside development and test.

The data plane server

109 variables.

VariableGroupRequiredDefaultWhat it does
NODE_ENVRuntimeyesWhich environment the process runs as: development, test, staging or production. Required, with no default: every hardening check keys on it, and a process started without it refuses to start. In staging and production extra checks refuse unsafe settings.
SERVICE_NAMERuntimenocdms-serverThe name the service reports in its logs and its build information.
SERVICE_VERSIONRuntimeno0.0.0The version the service reports in its build information.
PORTRuntimeno4000The port the API listens on.
HOSTRuntimeno0.0.0.0The address the API binds to.
CORS_ORIGINSRuntimenohttp://localhost:3000Comma-separated browser origins allowed to call the API.
REQUEST_BODY_LIMITRuntimeno2mbThe largest request body the API accepts.
TRUST_PROXYRuntimenoWho may set X-Forwarded-For. A hop COUNT (the previous 1) tells Express to believe the last hop from ANY peer, so a caller reaching the API directly could name its own origin IP and the audit ledger recorded it — proven live, from localhost, as 198.51.100.42. A comma-separated list of proxy addresses/CIDRs (or one of Express's presets: loopback, linklocal, uniquelocal) makes the header trustworthy only from the peer that sets it. The default is false — trust NOTHING — so req.ip is the socket peer and cannot be influenced by a header at all. A deployment behind a proxy has to name it, which is the right way round: forgetting the setting costs you the real client IP behind a proxy, and getting it wrong the other way costs you the integrity of the audit trail's attribution. Note loopback is NOT a safe default: it trusts the header from any local peer, which in a container or a sidecar is most of the network. A bare number is still accepted so an existing deployment does not break on upgrade — but assertProductionInvariants refuses it, because "trust the last hop from anyone" is not a thing a regulated deployment should do. Unset means false, but it is left undefined here rather than defaulted so production can tell "trust no proxy" (stated) from "never considered" (omitted), and refuse the second: every per-address control falls back to req.ip, so a deployment behind a proxy that forgot this counts everyone as the proxy.
BFF_CLIENT_ADDRESS_SECRET (a secret)RuntimenoShared secret that lets the Next.js BFF vouch for the address it forwards (B1). Every browser request reaches the API from the BFF, so req.ip is the BFF's own egress address for every user at once: keyed on it, one person's requests spent everyone's per-address allowance. The BFF sends the viewer's address in X-CDMS-Client-Address with this secret in X-CDMS-Client-Address-Token, and clientAddress() believes the address only with the secret. Unset, the header is never believed. Required in production and staging (productionInvariantProblems); set the same value on the BFF.
SHUTDOWN_TIMEOUT_MSRuntimeno20_000How long the process waits for work to finish when it is asked to stop.
LOG_LEVELLoggingnoinfoHow much the service logs: fatal, error, warn, info, debug, trace or silent.
LOG_PRETTYLoggingnofalseHuman-readable log lines instead of JSON (development).
LOG_REDACT_PATHSLoggingnoreq.headers.authorization,req.headers.cookie,*.password,*.token,*.secretLog fields whose values are replaced before writing (authorization and cookie headers, passwords, tokens, secrets).
DATABASE_URLDatabaseyesThe connection string of the RUNTIME database role: least privilege, unable to change or delete the append-only ledgers. The application connects as this role.
DATABASE_MIGRATION_URLDatabasenoThe connection string of the OWNER role that runs migrations and owns the tables. Only the migration runner uses it; the running application never connects with it. Staging and production refuse to run without a separate owner role.
DATABASE_POOL_MINDatabaseno2Fewest database connections kept open.
DATABASE_POOL_MAXDatabaseno20Most database connections open at once.
DATABASE_IDLE_TIMEOUT_MSDatabaseno30_000How long an idle database connection is kept before it is closed.
DATABASE_CONNECTION_TIMEOUT_MSDatabaseno5_000How long to wait for a database connection before failing.
DATABASE_STATEMENT_TIMEOUT_MSDatabaseno30_000The longest a single statement may run.
DATABASE_SSLDatabasenofalseUse TLS to the database. Production and staging refuse a connection that resolves to an unencrypted one.
JWT_ALGORITHMAuthnoHS256The signing algorithm of session tokens (HMAC only: HS256, HS384 or HS512).
JWT_SECRET (a secret)AuthyesThe key session tokens are signed with, at least 32 characters. Production and staging refuse a placeholder or a key without enough variety.
JWT_ISSUERAuthnocdmsThe issuer written into session tokens.
JWT_AUDIENCEAuthnocdms.apiThe audience written into session tokens.
JWT_ACCESS_TTLAuthno15mLifetime of an access token, for example 15m.
JWT_REFRESH_TTLAuthno7dLifetime of a refresh token, for example 7d.
SESSION_IDLE_TIMEOUT_MINAuthno15Minutes of inactivity after which a session ends. Default 15. A production check refuses a value over eight hours.
SESSION_ABSOLUTE_TIMEOUT_MINAuthno720Absolute session lifetime cap (minutes), regardless of activity. Re-login required after this.
OTP_DEV_CODE_IN_RESPONSEAuthnofalseReturn each one-time code — sign-in AND e-signature step-up — in the API response as devCode, so a developer can sign in without a mailbox. A separate, explicit opt-in (H-06). The code used to come back whenever the process was not hardened, which made it a consequence of NODE_ENV alone: forget NODE_ENV and anyone who knew an email address could sign in, and sign, as that person. Honoured only under NODE_ENV=development or test (returnsDevCodes), and refused at boot in production and staging (productionInvariantProblems).
PLATFORM_SERVICE_TOKEN (a secret)AuthnoThe shared secret the control plane presents to the data plane's provisioning and usage endpoints. Empty disables those endpoints: nothing can provision an organization.
CONTROL_PLANE_URLAuthnoCONTROL_PLANE_URL_DEFAULTWhere the data plane reads each organization's feature switches. A standalone installation with no control plane leaves the default and every feature is allowed.
CONTROL_PLANE_ENTITLEMENTS_KEY (a secret)AuthnoThe key the entitlements client presents (header X-Admin-Key). It is the control plane's PLATFORM_ENTITLEMENTS_KEY: accepted there by ONE route, the read of an organization's feature switches, and by nothing else. This is the key production uses. It replaced CONTROL_PLANE_ADMIN_KEY, which is the control plane's full-admin static key (every tenant, every administrator, the audit ledger) and was being carried on the request path of every gated route.
CONTROL_PLANE_ADMIN_KEY (a secret)AuthnoDEVELOPMENT FALLBACK ONLY: the control plane's full-admin key, used for the entitlements lookup when CONTROL_PLANE_ENTITLEMENTS_KEY is unset and the process is not production or staging. Never read in production or staging.
ENTITLEMENTS_CACHE_TTL_MSAuthno60_000How long a feature-switch lookup is remembered.
ENTITLEMENTS_TIMEOUT_MSAuthno2_000How long the entitlements lookup may take before it is abandoned (SA-67). This call is ON THE REQUEST PATH and had no timeout at all, so a control plane that accepted the connection and never answered held the data-plane request open for as long as the OS allowed. The gate's fail-open only engages once the fetch SETTLES, so "degrade to available" degraded to "hang" instead. Small on purpose. It gates a licensed feature; a person waiting to open the form builder should not wait seconds to find out whether their org is allowed to.
ENTITLEMENTS_FAILURE_TTL_MSAuthno10_000How long a FAILED lookup is remembered (SA-67). Nothing was cached on failure, so during a control-plane outage every gated request opened its own doomed socket — with no timeout, indefinitely. A third party being down became a resource-exhaustion path here. Much shorter than the success TTL so recovery is quick.
RUNTIME_CONFIG_CACHE_MAX_BYTESAuthno268_435_456Memory cap of the cache of compiled study-version snapshots.
RUNTIME_CONFIG_CACHE_MAX_ENTRIESAuthno256Most snapshots kept in that cache.
RATE_LIMIT_WINDOW_MSRate limitingno60_000The length of the window rate limits are counted in.
RATE_LIMIT_MAXRate limitingno600Requests allowed per client in a window, for ordinary routes.
RATE_LIMIT_AUTH_MAXRate limitingno30Requests allowed per client in a window on the sign-in routes. A production check refuses a value over 100.
AUDIT_VERIFY_INTERVAL_MSAudit ledgerno3_600_000Period (ms) between background audit hash-chain verifications in the worker. 0 disables the periodic tick (a one-shot verify still runs at worker boot).
AUDIT_VERIFY_FULL_INTERVAL_MSAudit ledgerno604_800_000How often (ms) the worker walks each audit chain in FULL, from its genesis (ADR 0017, decision 8). The hourly verification in between begins at each chain's last checkpoint, so nothing before a checkpoint is trusted for longer than this. Default: a week.
AUDIT_OUTBOX_DRAIN_INTERVAL_MSAudit ledgerno1_000How long (ms) an idle audit relay claim loop waits before it asks again (durable audit delivery, H8; ADR 0017, decision 7). 0 disables the periodic relay (a one-shot drain still runs at boot).
AUDIT_ANCHOR_STOREAudit ledgernononeWhere the daily anchors of the audit chains are written: none (default), local (files; proves the mechanism, not immutability) or s3 (S3 Object Lock in compliance mode; what production requires).
AUDIT_ANCHOR_REQUIREDAudit ledgernotrueWhether staging and production refuse to start without S3 anchors. Default true. false WAIVES the rule for a testing environment that holds no real data: the server starts with none and says so in its log at every start. Until anchors are on, events cut off the end of a chain are not detectable. Remove it (or set true) before real data goes in.
AUDIT_ANCHOR_INSTANCE_IDAudit ledgernocdmsWhich installation these anchors are from: two may share a bucket.
AUDIT_ANCHOR_PREFIXAudit ledgernoaudit-anchors/The common prefix of the anchors in the bucket or directory, ending in a slash.
AUDIT_ANCHOR_INTERVAL_MSAudit ledgerno3_600_000How often (ms) the worker checks whether today's anchor is taken. The anchor itself is daily.
AUDIT_ANCHOR_LOCAL_DIRAudit ledgerno.audit-anchorsThe directory the local anchor store writes to.
AUDIT_ANCHOR_S3_BUCKETAudit ledgernoThe bucket of the S3 anchor store.
AUDIT_ANCHOR_S3_REGIONAudit ledgernoThe region of the anchor bucket; falls back to S3_REGION.
AUDIT_ANCHOR_S3_ENDPOINTAudit ledgernoA custom endpoint for an S3-compatible anchor store.
AUDIT_ANCHOR_S3_FORCE_PATH_STYLEAudit ledgernofalsePath-style addressing for an S3-compatible anchor store.
AUDIT_ANCHOR_S3_ACCESS_KEY_IDAudit ledgernoThe anchor principal's own credentials (put, get and list; never delete or change a retention); omit to use the default AWS provider chain.
AUDIT_ANCHOR_S3_SECRET_ACCESS_KEY (a secret)Audit ledgernoThe anchor principal's secret key (a secret); omit to use the default AWS credential chain.
AUDIT_ANCHOR_RETENTION_DAYSAudit ledgerno9_125How long an anchor is retained in compliance mode, which no one can shorten: 25 years.
AUDIT_OUTBOX_PARALLEL_CHAINSAudit ledgerno4Claim loops the worker runs at once; each delivers a different chain's batch.
AUDIT_OUTBOX_LAG_ALERT_SECONDSAudit ledgerno30Seconds the oldest pending audit envelope of any chain may wait before the outbox is reported behind (and an alert is raised).
SUMMARY_PROJECT_INTERVAL_MSAudit ledgerno1_000Period (ms) between the worker's projector steps: it recomputes the subjects that changed. 0 disables it (the numbers then go stale; a read of one study still makes that study's current before it reads).
SUMMARY_RECONCILE_INTERVAL_MSAudit ledgerno15 * 60 * 1000Period (ms) between reconcile steps: each looks at the next few studies' rows against the raw tables. 0 disables it.
SUMMARY_RECONCILE_SCOPES_PER_TICKAudit ledgerno20Studies (and containers) one reconcile step looks at.
SUMMARY_LAG_ALERT_SECONDSAudit ledgerno60Seconds the oldest change the projector has not taken up may wait before an alert is raised.
ALERT_EMAILSOperational alertingnoComma-separated operator addresses for integrity/queue alerts. Empty disables delivery; alerts still log at error level.
ALERT_COOLDOWN_MSOperational alertingno6 * 60 * 60 * 1000Minimum gap (ms) between repeat alerts for the SAME condition, so a fault that persists across ticks does not mail on every tick. Recovery notices are never suppressed — a resolution is always worth exactly one message.
HEALTH_SWEEP_INTERVAL_MSOperational alertingno60_000Period (ms) between worker health sweeps (outbox backlog + export queue). 0 disables the sweep.
EXPORT_DRAIN_INTERVAL_MSOperational alertingno10_000Period (ms) between export-queue drains on the worker. 0 disables it — which stops exports being produced at all, so only for a maintenance box.
AI_PROVIDERFeature flagsnononenone (default: the AI endpoints answer 501) or vertex (Gemini on Vertex AI, authenticated with a Google Cloud service-account key; processing stays in the configured region).
GOOGLE_CLOUD_PROJECTFeature flagsnoThe Google Cloud project for Vertex AI. Required when AI_PROVIDER=vertex.
GOOGLE_CLOUD_LOCATIONFeature flagsnous-central1The Vertex AI region: the data-residency choice.
GOOGLE_APPLICATION_CREDENTIALSFeature flagsnoPath to the service-account key file. Required when AI_PROVIDER=vertex.
AI_MODELFeature flagsnogemini-2.5-flashThe primary model the AI features call.
AI_FALLBACK_MODELFeature flagsnogemini-2.5-flash-liteLighter, higher-capacity model used when the primary keeps returning transient overload (503) / timeouts. Empty disables fallback.
AI_TIMEOUT_MSFeature flagsno45_000How long to wait for the AI provider.
AI_MAX_FIELDSFeature flagsno60Hard cap on fields the model may produce — guards prompt-injection blow-ups.
AI_FORM_REPAIRFeature flagsnotrueWhen a generated form's formulas fail the check (an unknown field, a letter-valued choice, a loop), ask the model ONCE to correct them. One extra paid call at most, only for such a form; off, the browser drops what cannot be used and says so.
AI_ROLE_ASSISTANT_MAX_PER_HOURFeature flagsno30The role assistant's calls an organization may make in an hour (suggestions and explanations together): each is a paid call to the provider.
STORAGE_DRIVERFeature flagsnolocalWhere documents and export files are kept: local (the filesystem; cannot make real pre-signed links) or s3 (production).
S3_BUCKETFeature flagsnoThe bucket for documents and exports. Required when STORAGE_DRIVER=s3.
S3_REGIONFeature flagsnoThe region of that bucket. Required when STORAGE_DRIVER=s3.
S3_ENDPOINTFeature flagsnoCustom endpoint for S3-compatible stores (MinIO/localstack); empty = AWS.
S3_FORCE_PATH_STYLEFeature flagsnofalseRequired true for most S3-compatible endpoints; false for native AWS.
S3_ACCESS_KEY_IDFeature flagsnoOptional static credentials; omit to use the default AWS provider chain.
S3_SECRET_ACCESS_KEY (a secret)Feature flagsnoThe storage principal's secret key (a secret); omit to use the default AWS credential chain.
S3_SSEFeature flagsnoServer-side encryption at rest: 'AES256' (S3-managed) or 'aws:kms'. Omit to rely on bucket default encryption.
S3_SSE_KMS_KEY_IDFeature flagsnoKMS key id/ARN when S3_SSE=aws:kms (omit to use the bucket's default KMS key).
S3_PRESIGN_TTL_SECONDSFeature flagsno300Default presigned-URL lifetime in seconds.
DOCUMENT_MAX_UPLOAD_BYTESFeature flagsno104_857_600Max upload size accepted for a document file (bytes). Default 100 MB.
MAIL_ENABLEDEmail / notificationsnotrueTurn mail sending on or off.
MAIL_FROMEmail / notificationsnoCDMS <no-reply@cdms.local>The sender shown on every mail.
APP_BASE_URLEmail / notificationsnohttp://localhost:3000Base URL used to build links inside emails (sign-in, study, etc.).
SMTP_HOSTEmail / notificationsnoWhen set, real SMTP is used; otherwise a log transport renders the email.
SMTP_PORTEmail / notificationsno587The mail server's port.
SMTP_SECUREEmail / notificationsnofalseTLS from the first byte (port 465) instead of STARTTLS.
SMTP_USEREmail / notificationsnoThe mail server user name.
SMTP_PASS (a secret)Email / notificationsnoThe mail server password (a secret).
MAIL_WEBHOOK_SECRET (a secret)Email / notificationsnoThe secret a mail service signs its delivery events with (an HMAC-SHA256 of the timestamp and the body). Unset, the endpoint that takes the events refuses every request, and the statuses stop at sent: with plain SMTP nothing reports a delivery, and the screens say so.
MAIL_WEBHOOK_TOLERANCE_SECONDSEmail / notificationsno300How far a delivery event's timestamp may be from now, in seconds, before it is refused as a replay.
MAIL_RELAY_INTERVAL_MSEmail / notificationsno2_000How often the worker looks for mail to send. 0 turns the relay off (a one-shot pass still runs at boot).
MAIL_RELAY_BATCHEmail / notificationsno20Messages claimed at once.
MAIL_MAX_ATTEMPTSEmail / notificationsno10Attempts before a message is failed. Each failure doubles the wait (2, 4, 8 ... seconds, at most ten minutes).
MAIL_LEASE_SECONDSEmail / notificationsno120How long a claim holds a message: a worker that dies mid-send has it retried when the lease ends.
MAIL_LAG_ALERT_SECONDSEmail / notificationsno300Alert when a message has waited this long to be sent.
MAIL_RELAY_IDLE_SECONDSEmail / notificationsno120Say that no relay is running when a message has waited this long and nobody has so much as tried to send it. The API checks this itself, because the worker's own health sweep cannot report that the worker is missing.
MAIL_RELAY_WATCH_INTERVAL_MSEmail / notificationsno60_000How often the API looks for a missing relay, in ms. 0 turns the watch off (the start-up line is still said).
MAIL_DEV_ECHO_LINKSEmail / notificationsnofalseDevelopment only: print an invitation's link in the worker's log when its mail is sent, so a developer without a mail service can finish an invitation. An invitation's link is a credential and is never logged otherwise; production and staging refuse to boot with this on.
INVITATION_TTL_HOURSEmail / notificationsno72How long an invitation link works, in hours: from the moment the mail is sent. A fresh link can be asked for on the same page.
INVITATION_RESEND_COOLDOWN_SECONDSEmail / notificationsno60The least time between two invitation mails to one person, whoever asks (a person who asks again, an administrator who resends).

The control plane server

20 variables.

VariableGroupRequiredDefaultWhat it does
NODE_ENVnodevelopmentWhich environment the console's server runs as. Production and staging refuse the development shortcuts.
PORTno4100The port the control plane's API listens on.
LOG_LEVELnoinfoHow much the service logs.
DATABASE_URLyesThe RUNTIME role: what this process connects as. Least privilege (each table's migration); it cannot rewrite the platform ledger.
DATABASE_MIGRATION_URLnoThe OWNER role, which runs the migrations and owns the tables. Only scripts/migrate.mjs connects with it; this process never does. It is read here so production and staging can refuse a deployment that has no separate owner.
PLATFORM_JWT_SECRET (a secret)yesThe key console sessions are signed with, at least 32 characters (a secret).
PLATFORM_ADMIN_API_KEY (a secret)yesA static machine key with full administrator rights, for scripts. It reads every organization, every administrator and the audit ledger (a secret).
PLATFORM_ENTITLEMENTS_KEY (a secret)noA machine credential that can do ONE thing: read an organization's feature switches (GET /api/platform/organizations/:orgId/entitlements). The data plane asks on the request path of every gated route, and it used to carry PLATFORM_ADMIN_API_KEY to do it, a key that reads every tenant, every administrator and the whole audit ledger. This one is accepted by that single route and by nothing else. Empty means no such caller is configured.
DATA_PLANE_URLnohttp://localhost:4000/api/v1The base address of the data plane's API, which the control plane calls to provision and suspend organizations and to read usage.
DATA_PLANE_SERVICE_TOKEN (a secret)noThe shared secret the control plane presents to the data plane; the data plane's PLATFORM_SERVICE_TOKEN must hold the same value (a secret).
CORS_ORIGINSnoBrowser origins allowed to call the control plane. Empty means same-origin only, the right choice for a console served from its own origin.
CONSOLE_URLnoWhere the admin console is served, e.g. https://console.example.com. The invitation mail to a new administrator carries it as a link; empty, the mail says to open the console "at the address your team uses for it". Not a secret and not checked at boot.
AUTH_OTP_TRANSPORTnologHow a sign-in code reaches its recipient. smtp the code is mailed (SMTP_HOST, MAIL_FROM and the rest below). The only transport a deployed control plane can use. log development only: the code is printed to the console of a process running as NODE_ENV=development. Production and staging refuse to boot on it, instead of starting happily with a passwordless sign-in that never delivers anything (SA-29).
SMTP_HOSTnoThe mail server for the smtp transport, and the address the code is sent from, e.g. "Trialion Platform no-reply@example.com".
SMTP_PORTno587The mail server's port for the sign-in code mail.
SMTP_SECUREnofalsetrue = TLS from the first byte (port 465); false = STARTTLS when the server offers it (port 587).
SMTP_USERnoThe mail server user name.
SMTP_PASS (a secret)noThe mail server password (a secret).
MAIL_FROMnoThe sender of the sign-in code and invitation mails, for example Trialion Platform no-reply@example.com.
AUTH_EXPOSE_DEV_OTPnofalseReturn the OTP in the HTTP response. Development convenience, and the reason it now needs its OWN opt-in: it used to be implied by NODE_ENV=development alone, so a staging box misconfigured as development handed full Super-Admin to any unauthenticated caller who could reach the port — POST an email, read devCode from the JSON, POST it back.

Running it · The database · Architecture · Environments and versioning