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.
| Variable | Group | Required | Default | What it does |
|---|---|---|---|---|
NODE_ENV | Runtime | yes | Which 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_NAME | Runtime | no | cdms-server | The name the service reports in its logs and its build information. |
SERVICE_VERSION | Runtime | no | 0.0.0 | The version the service reports in its build information. |
PORT | Runtime | no | 4000 | The port the API listens on. |
HOST | Runtime | no | 0.0.0.0 | The address the API binds to. |
CORS_ORIGINS | Runtime | no | http://localhost:3000 | Comma-separated browser origins allowed to call the API. |
REQUEST_BODY_LIMIT | Runtime | no | 2mb | The largest request body the API accepts. |
TRUST_PROXY | Runtime | no | Who 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) | Runtime | no | Shared 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_MS | Runtime | no | 20_000 | How long the process waits for work to finish when it is asked to stop. |
LOG_LEVEL | Logging | no | info | How much the service logs: fatal, error, warn, info, debug, trace or silent. |
LOG_PRETTY | Logging | no | false | Human-readable log lines instead of JSON (development). |
LOG_REDACT_PATHS | Logging | no | req.headers.authorization,req.headers.cookie,*.password,*.token,*.secret | Log fields whose values are replaced before writing (authorization and cookie headers, passwords, tokens, secrets). |
DATABASE_URL | Database | yes | The 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_URL | Database | no | The 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_MIN | Database | no | 2 | Fewest database connections kept open. |
DATABASE_POOL_MAX | Database | no | 20 | Most database connections open at once. |
DATABASE_IDLE_TIMEOUT_MS | Database | no | 30_000 | How long an idle database connection is kept before it is closed. |
DATABASE_CONNECTION_TIMEOUT_MS | Database | no | 5_000 | How long to wait for a database connection before failing. |
DATABASE_STATEMENT_TIMEOUT_MS | Database | no | 30_000 | The longest a single statement may run. |
DATABASE_SSL | Database | no | false | Use TLS to the database. Production and staging refuse a connection that resolves to an unencrypted one. |
JWT_ALGORITHM | Auth | no | HS256 | The signing algorithm of session tokens (HMAC only: HS256, HS384 or HS512). |
JWT_SECRET (a secret) | Auth | yes | The key session tokens are signed with, at least 32 characters. Production and staging refuse a placeholder or a key without enough variety. | |
JWT_ISSUER | Auth | no | cdms | The issuer written into session tokens. |
JWT_AUDIENCE | Auth | no | cdms.api | The audience written into session tokens. |
JWT_ACCESS_TTL | Auth | no | 15m | Lifetime of an access token, for example 15m. |
JWT_REFRESH_TTL | Auth | no | 7d | Lifetime of a refresh token, for example 7d. |
SESSION_IDLE_TIMEOUT_MIN | Auth | no | 15 | Minutes of inactivity after which a session ends. Default 15. A production check refuses a value over eight hours. |
SESSION_ABSOLUTE_TIMEOUT_MIN | Auth | no | 720 | Absolute session lifetime cap (minutes), regardless of activity. Re-login required after this. |
OTP_DEV_CODE_IN_RESPONSE | Auth | no | false | Return 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) | Auth | no | The 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_URL | Auth | no | CONTROL_PLANE_URL_DEFAULT | Where 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) | Auth | no | The 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) | Auth | no | DEVELOPMENT 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_MS | Auth | no | 60_000 | How long a feature-switch lookup is remembered. |
ENTITLEMENTS_TIMEOUT_MS | Auth | no | 2_000 | How 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_MS | Auth | no | 10_000 | How 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_BYTES | Auth | no | 268_435_456 | Memory cap of the cache of compiled study-version snapshots. |
RUNTIME_CONFIG_CACHE_MAX_ENTRIES | Auth | no | 256 | Most snapshots kept in that cache. |
RATE_LIMIT_WINDOW_MS | Rate limiting | no | 60_000 | The length of the window rate limits are counted in. |
RATE_LIMIT_MAX | Rate limiting | no | 600 | Requests allowed per client in a window, for ordinary routes. |
RATE_LIMIT_AUTH_MAX | Rate limiting | no | 30 | Requests allowed per client in a window on the sign-in routes. A production check refuses a value over 100. |
AUDIT_VERIFY_INTERVAL_MS | Audit ledger | no | 3_600_000 | Period (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_MS | Audit ledger | no | 604_800_000 | How 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_MS | Audit ledger | no | 1_000 | How 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_STORE | Audit ledger | no | none | Where 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_REQUIRED | Audit ledger | no | true | Whether 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_ID | Audit ledger | no | cdms | Which installation these anchors are from: two may share a bucket. |
AUDIT_ANCHOR_PREFIX | Audit ledger | no | audit-anchors/ | The common prefix of the anchors in the bucket or directory, ending in a slash. |
AUDIT_ANCHOR_INTERVAL_MS | Audit ledger | no | 3_600_000 | How often (ms) the worker checks whether today's anchor is taken. The anchor itself is daily. |
AUDIT_ANCHOR_LOCAL_DIR | Audit ledger | no | .audit-anchors | The directory the local anchor store writes to. |
AUDIT_ANCHOR_S3_BUCKET | Audit ledger | no | The bucket of the S3 anchor store. | |
AUDIT_ANCHOR_S3_REGION | Audit ledger | no | The region of the anchor bucket; falls back to S3_REGION. | |
AUDIT_ANCHOR_S3_ENDPOINT | Audit ledger | no | A custom endpoint for an S3-compatible anchor store. | |
AUDIT_ANCHOR_S3_FORCE_PATH_STYLE | Audit ledger | no | false | Path-style addressing for an S3-compatible anchor store. |
AUDIT_ANCHOR_S3_ACCESS_KEY_ID | Audit ledger | no | The 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 ledger | no | The anchor principal's secret key (a secret); omit to use the default AWS credential chain. | |
AUDIT_ANCHOR_RETENTION_DAYS | Audit ledger | no | 9_125 | How long an anchor is retained in compliance mode, which no one can shorten: 25 years. |
AUDIT_OUTBOX_PARALLEL_CHAINS | Audit ledger | no | 4 | Claim loops the worker runs at once; each delivers a different chain's batch. |
AUDIT_OUTBOX_LAG_ALERT_SECONDS | Audit ledger | no | 30 | Seconds the oldest pending audit envelope of any chain may wait before the outbox is reported behind (and an alert is raised). |
SUMMARY_PROJECT_INTERVAL_MS | Audit ledger | no | 1_000 | Period (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_MS | Audit ledger | no | 15 * 60 * 1000 | Period (ms) between reconcile steps: each looks at the next few studies' rows against the raw tables. 0 disables it. |
SUMMARY_RECONCILE_SCOPES_PER_TICK | Audit ledger | no | 20 | Studies (and containers) one reconcile step looks at. |
SUMMARY_LAG_ALERT_SECONDS | Audit ledger | no | 60 | Seconds the oldest change the projector has not taken up may wait before an alert is raised. |
ALERT_EMAILS | Operational alerting | no | Comma-separated operator addresses for integrity/queue alerts. Empty disables delivery; alerts still log at error level. | |
ALERT_COOLDOWN_MS | Operational alerting | no | 6 * 60 * 60 * 1000 | Minimum 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_MS | Operational alerting | no | 60_000 | Period (ms) between worker health sweeps (outbox backlog + export queue). 0 disables the sweep. |
EXPORT_DRAIN_INTERVAL_MS | Operational alerting | no | 10_000 | Period (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_PROVIDER | Feature flags | no | none | none (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_PROJECT | Feature flags | no | The Google Cloud project for Vertex AI. Required when AI_PROVIDER=vertex. | |
GOOGLE_CLOUD_LOCATION | Feature flags | no | us-central1 | The Vertex AI region: the data-residency choice. |
GOOGLE_APPLICATION_CREDENTIALS | Feature flags | no | Path to the service-account key file. Required when AI_PROVIDER=vertex. | |
AI_MODEL | Feature flags | no | gemini-2.5-flash | The primary model the AI features call. |
AI_FALLBACK_MODEL | Feature flags | no | gemini-2.5-flash-lite | Lighter, higher-capacity model used when the primary keeps returning transient overload (503) / timeouts. Empty disables fallback. |
AI_TIMEOUT_MS | Feature flags | no | 45_000 | How long to wait for the AI provider. |
AI_MAX_FIELDS | Feature flags | no | 60 | Hard cap on fields the model may produce — guards prompt-injection blow-ups. |
AI_FORM_REPAIR | Feature flags | no | true | When 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_HOUR | Feature flags | no | 30 | The role assistant's calls an organization may make in an hour (suggestions and explanations together): each is a paid call to the provider. |
STORAGE_DRIVER | Feature flags | no | local | Where documents and export files are kept: local (the filesystem; cannot make real pre-signed links) or s3 (production). |
S3_BUCKET | Feature flags | no | The bucket for documents and exports. Required when STORAGE_DRIVER=s3. | |
S3_REGION | Feature flags | no | The region of that bucket. Required when STORAGE_DRIVER=s3. | |
S3_ENDPOINT | Feature flags | no | Custom endpoint for S3-compatible stores (MinIO/localstack); empty = AWS. | |
S3_FORCE_PATH_STYLE | Feature flags | no | false | Required true for most S3-compatible endpoints; false for native AWS. |
S3_ACCESS_KEY_ID | Feature flags | no | Optional static credentials; omit to use the default AWS provider chain. | |
S3_SECRET_ACCESS_KEY (a secret) | Feature flags | no | The storage principal's secret key (a secret); omit to use the default AWS credential chain. | |
S3_SSE | Feature flags | no | Server-side encryption at rest: 'AES256' (S3-managed) or 'aws:kms'. Omit to rely on bucket default encryption. | |
S3_SSE_KMS_KEY_ID | Feature flags | no | KMS key id/ARN when S3_SSE=aws:kms (omit to use the bucket's default KMS key). | |
S3_PRESIGN_TTL_SECONDS | Feature flags | no | 300 | Default presigned-URL lifetime in seconds. |
DOCUMENT_MAX_UPLOAD_BYTES | Feature flags | no | 104_857_600 | Max upload size accepted for a document file (bytes). Default 100 MB. |
MAIL_ENABLED | Email / notifications | no | true | Turn mail sending on or off. |
MAIL_FROM | Email / notifications | no | CDMS <no-reply@cdms.local> | The sender shown on every mail. |
APP_BASE_URL | Email / notifications | no | http://localhost:3000 | Base URL used to build links inside emails (sign-in, study, etc.). |
SMTP_HOST | Email / notifications | no | When set, real SMTP is used; otherwise a log transport renders the email. | |
SMTP_PORT | Email / notifications | no | 587 | The mail server's port. |
SMTP_SECURE | Email / notifications | no | false | TLS from the first byte (port 465) instead of STARTTLS. |
SMTP_USER | Email / notifications | no | The mail server user name. | |
SMTP_PASS (a secret) | Email / notifications | no | The mail server password (a secret). | |
MAIL_WEBHOOK_SECRET (a secret) | Email / notifications | no | The 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_SECONDS | Email / notifications | no | 300 | How far a delivery event's timestamp may be from now, in seconds, before it is refused as a replay. |
MAIL_RELAY_INTERVAL_MS | Email / notifications | no | 2_000 | How often the worker looks for mail to send. 0 turns the relay off (a one-shot pass still runs at boot). |
MAIL_RELAY_BATCH | Email / notifications | no | 20 | Messages claimed at once. |
MAIL_MAX_ATTEMPTS | Email / notifications | no | 10 | Attempts before a message is failed. Each failure doubles the wait (2, 4, 8 ... seconds, at most ten minutes). |
MAIL_LEASE_SECONDS | Email / notifications | no | 120 | How long a claim holds a message: a worker that dies mid-send has it retried when the lease ends. |
MAIL_LAG_ALERT_SECONDS | Email / notifications | no | 300 | Alert when a message has waited this long to be sent. |
MAIL_RELAY_IDLE_SECONDS | Email / notifications | no | 120 | Say 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_MS | Email / notifications | no | 60_000 | How 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_LINKS | Email / notifications | no | false | Development 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_HOURS | Email / notifications | no | 72 | How 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_SECONDS | Email / notifications | no | 60 | The 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.
| Variable | Group | Required | Default | What it does |
|---|---|---|---|---|
NODE_ENV | no | development | Which environment the console's server runs as. Production and staging refuse the development shortcuts. | |
PORT | no | 4100 | The port the control plane's API listens on. | |
LOG_LEVEL | no | info | How much the service logs. | |
DATABASE_URL | yes | The RUNTIME role: what this process connects as. Least privilege (each table's migration); it cannot rewrite the platform ledger. | ||
DATABASE_MIGRATION_URL | no | The 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) | yes | The key console sessions are signed with, at least 32 characters (a secret). | ||
PLATFORM_ADMIN_API_KEY (a secret) | yes | A 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) | no | A 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_URL | no | http://localhost:4000/api/v1 | The 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) | no | The 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_ORIGINS | no | Browser origins allowed to call the control plane. Empty means same-origin only, the right choice for a console served from its own origin. | ||
CONSOLE_URL | no | Where 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_TRANSPORT | no | log | How 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_HOST | no | The mail server for the smtp transport, and the address the code is sent from, e.g. "Trialion Platform no-reply@example.com". | ||
SMTP_PORT | no | 587 | The mail server's port for the sign-in code mail. | |
SMTP_SECURE | no | false | true = TLS from the first byte (port 465); false = STARTTLS when the server offers it (port 587). | |
SMTP_USER | no | The mail server user name. | ||
SMTP_PASS (a secret) | no | The mail server password (a secret). | ||
MAIL_FROM | no | The sender of the sign-in code and invitation mails, for example Trialion Platform no-reply@example.com. | ||
AUTH_EXPOSE_DEV_OTP | no | false | Return 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. |
Related
Running it · The database · Architecture · Environments and versioning