Skip to content

Configuration Map

Where every configuration value comes from, and what wins when the same key is set in more than one place.

Floh reads configuration from up to six layers, and the precedence between them is not the same in local development as it is on a deployed stack. If you are debugging a value that "isn't taking effect", start with Which layer wins and then check Layers that bypass the rules — most surprises live there.

The three runtime shapes

The same repo runs in three configurations with genuinely different precedence. Identify which one you are in before reasoning about any value.

Shape How you start it Public config bundle loaded? Consequence
Local host-Node pnpm dev, pnpm dev:server Yes (development.env) Bundle wins for non-blank keys, but a sanitizer removes the keys that would break local dev
Local Docker Compose pnpm docker:up No Every non-secret comes from .env and the compose environment: block
Deployed Docker Compose Deploy workflow → ~/floh Yes (ci.env) ci.env beats the host .env for every non-secret, with no fallback

"Public config bundle" means the files in config/public/base.env plus one tier file selected by APP_ENV (falling back to NODE_ENV). These are committed to git and owned by contributors, not operators.

Layers, in load order

Load order is not precedence. The tables below list the layers in the order they are assembled, which is the order you will meet them when setting a value up. It is not the order in which they win — the public config bundle takes priority over both .env and the shell for any key it defines non-blank, in every runtime shape. See Which layer wins for the actual resolution rule; read it before concluding that a later row overrides an earlier one.

Local host-Node development

# Layer Path Owner In git Loaded by
1 Code defaults inline ?? "fallback" per key contributor yes packages/server/src/config/index.ts
2 Public config base config/public/base.env contributor yes packages/server/src/config/public-config.ts
3 Public config tier config/public/development.env contributor yes same, merged over base.env
4 Overlay sanitizer (subtractive) n/a — deletes keys from 2–3 contributor yes packages/server/src/config/index.ts
5 Your local env file .env (from .env.example) you no Node --env-file, via packages/server/package.json
6 Shell environment export FOO=… before the command you n/a Node does not let --env-file override an already-set variable

Layers 5 and 6 reach the server only for keys that survive layer 4 — that is, keys the merged bundle leaves blank or never defines. Local dev feels like .env wins because the sanitizer deletes most keys from the overlay in development, not because .env outranks the bundle. The keys it does not delete are listed in The two keys that silently beat your .env.

Deployed Docker Compose

# Layer Path Owner In git Notes
1 Code defaults inline per key contributor yes
2 Host env file ~/floh/.env workflow no Regenerated from GitHub environment secrets/vars on every deploy
3 Compose environment: block docker/docker-compose.deploy.yml contributor yes Sets APP_ENV=ci and PUBLIC_CONFIG_DIR, which is what makes layer 5 authoritative
4 Public config base config/public/base.env~/floh/public contributor yes Copied to the host by the deploy
5 Public config tier config/public/ci.env contributor yes Highest precedence for every non-secret

Anything you change in the GitHub dev environment lands in layer 2. Anything in layer 4 or 5 requires a merged commit. That asymmetry is the single most common source of "I changed the secret/variable and nothing happened".

Which layer wins

The whole rule lives in one function, readNonSecret in packages/server/src/config/index.ts, and it is worth stating precisely:

  1. If the public bundle defines the key with a non-blank value, that value wins — immediately, without consulting process.env at all. No local .env entry, no exported shell variable, and no host ~/floh/.env line can override it.
  2. If the bundle defines the key but leaves it blank, or omits it entirely, process.env supplies the value — but only when legacy-env fallback is enabled.
  3. Legacy-env fallback is enabled when NODE_ENV=development, when NODE_ENV=test with APP_ENV=development, or when ALLOW_LEGACY_ENV_NON_SECRET=true. On a deployed stack ci.env sets NODE_ENV=production, so none of these apply and a key the bundle omits resolves to undefined rather than falling back.

Summary:

Key class Local host-Node Local Docker Deploy
Non-secret, set non-blank in the bundle bundle wins bundle not loaded ci.env > base.env > .env
Non-secret, absent or blank in the bundle your .env / shell your .env undefined — no fallback
Non-secret removed by the sanitizer your .env wins n/a sanitizer's dev rules do not fire
Any secret process.env only process.env only ~/floh/.env, or the Authifi vault
Same key in compose env_file and environment: n/a environment: wins environment: wins

Why local dev feels like .env always wins

It mostly does, but not because of precedence — because of a subtractive sanitizer that deletes collision-prone keys from the overlay before readNonSecret ever sees them. When NODE_ENV=development it removes DB_PORT unconditionally, and removes DB_HOST, REDIS_HOST, and SMTP_HOST only when they still hold their Docker service names (postgres, redis, mailhog). It also removes placeholder OIDC values (an issuer containing your-idp, client IDs starting with your-).

This is value-conditional, which makes it fragile in a specific way: change base.env's DB_HOST to anything other than the literal string postgres and local development breaks, because the deletion no longer fires and the bundle value starts winning.

The two keys that silently beat your .env

config/public/base.env sets these non-blank, and the sanitizer does not remove them, so they override your local .env even in local development, with no warning:

Key base.env value Effect
SHOW_ERROR_DETAILS false SHOW_ERROR_DETAILS=true in .env is ignored
TRUST_PROXY true TRUST_PROXY=false in .env is ignored

The same applies to every other non-blank base.env key the API server reads — PORT, HOST, LOG_LEVEL, DB_NAME, DB_USER, REDIS_PORT, SMTP_PORT, SMTP_FROM, OIDC_SCOPE — but those rarely need a local override, so they surprise people less.

This section is about the API server only. Everything above describes readNonSecret, which lives in packages/server. The BFFs do not use it and never load the public config bundle at all. The pnpm dev:portal and pnpm dev:console launchers read the root .env and the matching env/*.env themselves, merge them into one source object, and pass a computed set of values to the BFF process they spawn. So for the key that path carries — FLOH_INTERNAL_URL — your .env really is authoritative, and a base.env entry for the same key only affects the API server's own view of it.

Whether BFF_PROXY_TARGET is an input depends on the runtime, which is the easy thing to get wrong:

Runtime Behaviour
Host (default) Ignored. mapAuthEnv / mapConsoleAuthEnv compute the target with proxyTargetFromInternalUrl(sourceEnv.FLOH_INTERNAL_URL, mode) and never read a supplied value, and withoutSingularConsoleProxyEnv strips the key outright. Setting it in .env does nothing — change FLOH_INTERNAL_URL instead
Docker (FLOH_CONSOLE_BFF_RUNTIME=docker, FLOH_PORTAL_BFF_RUNTIME=docker) Honoured. resolveBffProxyTarget takes an explicit BFF_PROXY_TARGET from the process environment or from .env and passes it through, falling back to the Docker DNS default. This is the supported way to point a containerised gateway at a non-default API

Two caveats on that list:

  • SERVER_CERT / SERVER_KEY are not read from your .env in local development. The launcher reads the certificate files and injects the PEMs into the spawned process itself.
  • PORTAL_PORT and PORTAL_HOST are declared in base.env but nothing reads them. The BFF listen port is SERVER_PORT, fixed per launcher path and per compose file. Setting either key anywhere has no effect (#1184).

Secrets are never layered

Secret keys are resolved by a separate provider that reads only process.env (or the Authifi vault). The public config loader actively rejects secret keys, so a secret can never come from config/public/*.env no matter what you put there. In practice: secrets come from your .env locally, and from one of three host files (or the vault) on a deployed stack.

A deployed stack does not keep all its secrets in one file. The deploy writes three, and each service mounts only what it needs:

Host file Mounted by Holds
~/floh/.env server (the API) DB_PASSWORD, JWT_SECRET, SESSION_SECRET, CONNECTOR_ENCRYPTION_KEY, SMTP, …
~/floh/env/console.env console-bff OIDC_CLIENT_SECRET, CONSOLE_BFF_COOKIE_ENCRYPTION_SECRET, the console TLS PEMs
~/floh/env/portal.env portal-bff PORTAL_OIDC_CLIENT_SECRET, PORTAL_BFF_COOKIE_ENCRYPTION_SECRET, the portal TLS PEMs

The split is deliberate: the BFF relying-party credentials are deliberately kept out of ~/floh/.env so the API container never receives them. Two consequences worth internalising:

  • Adding a BFF credential to ~/floh/.env does not make it reach the BFF. The gateway still starts without the value and login fails, while the API now holds a credential it has no use for.
  • Every docker compose call needs all three --env-file flags, because interpolation draws on the union of them. See Running compose commands on the host.

Where a vault-backed stack changes this

SECRETS_BACKEND selects one provider, not a chain: createSecretProvider returns either AuthifiSecretProvider or EnvSecretProvider, with no fallback between them. So when SECRETS_BACKEND=authifi, the table above stops describing where the API's secrets come from, and rotating a value in the GitHub environment has no effect on the server no matter how many green deploys follow.

Secret Where to change it under SECRETS_BACKEND=authifi
API secrets — JWT_SECRET, SESSION_SECRET, SESSION_ENCRYPTION_KEY, AUDIT_CHECKPOINT_KEY, CONNECTOR_ENCRYPTION_KEY, SMTP credentials The Authifi vault. The GitHub secret is inert for the server
BFF and infrastructure secrets — the OIDC client secrets, BFF cookie secrets, BFF TLS PEMs Still the GitHub environment. The BFFs are prebuilt gateway images and never consult the vault
DB_PASSWORD No store is sufficient — follow Rotating DB_PASSWORD. The server reads it through the secret provider (so the vault), Compose interpolates ${DB_PASSWORD} from ~/floh/.env, and the live Postgres role has to be altered separately

The DB_PASSWORD row is the trap, and it is worse than a two-store update. Compose passes the value as POSTGRES_PASSWORD, which Postgres honors only while initializing an empty data directory. The volume survives every subsequent deploy, so the persisted floh role keeps whatever password it was created with no matter how many stores you edit. Updating the vault and ~/floh/.env therefore changes what the server sends without changing what the database expects, and the next restart loses database access entirely.

Rotating it requires an ALTER ROLE against the running database in the same operation, which is why it has its own sequence in the runbook: Rotating DB_PASSWORD. Do not treat the table above as the complete update set for this one key.

Layers that bypass the rules

These read raw process.env directly and are resolved before the config system exists. Setting them in config/public/*.env has no effect — on a deployed stack they must be in ~/floh/.env.

Reader Keys Why it bypasses
Observability bootstrap OTEL_*, DEPLOYMENT_ENVIRONMENT Imported on the first line of index.ts, before config loads
Secret-provider selection SECRETS_BACKEND Chooses the provider that config then uses; throws on an unrecognized value
Authifi vault locators AUTHIFI_VAULT_URL, _TENANT, _TENANT_ID, _CLIENT_ID, _KEY_FILE, _SECRET_PREFIX, _SCOPE Needed to fetch secrets before config exists
Critical-alert configuration CRITICAL_ALERT_ENABLED, _RECIPIENTS, _CONNECTOR_NAME, _COOLDOWN_MS Read from process.env at app-build time
Worker split WORKER_MODE Read in app.ts when deciding whether to start the in-process BullMQ worker
Connector tracing CONNECTOR_DEBUG Read per call by the connector logger
SMS webhook signature guard WEBHOOK_SIG_FAILURE_THRESHOLD, WEBHOOK_SIG_FAILURE_WINDOW_MS Read at route registration in webhook-routes.ts

The required vault locators are the clearest instance of the trap: the vault URL, tenant, tenant id, client id, and key file are declared in ci.env and copied into ~/floh/.env by the deploy. That duplication is deliberate. Removing the ~/floh/.env copy as "redundant" breaks the vault cutover with a Missing required Authifi vault env vars failure, because the ci.env copy never reaches process.env.

The claim stops at those five. AUTHIFI_VAULT_SECRET_PREFIX and AUTHIFI_VAULT_SCOPE are read from process.env by loadVaultConfigFromEnv but appear in neither ci.env nor the workflow's parser and host-file write lists, so setting either one is inert and the server silently takes its default — see the not-wired list and #1186.

One value has no configuration layer at all: the API rate limit is hardcoded to 200 requests per minute in packages/server/src/app.ts, despite the settings API exposing rateLimitEnabled / rateLimitMax fields that suggest otherwise.

The database layer

A small number of settings live in the system_setting table, not in any file. Operators change them through Admin → Settings in the console (requires the settings:manage permission) or, in a pinch, with SQL against system_setting WHERE key = 'system'.

Only three of the fields the settings API exposes actually affect runtime behavior. The rest are read-only echoes of env-var config, which makes the settings screen misleading if you assume everything on it is live:

Setting Effective? Interaction with env vars
criticalAlertEnabled, criticalAlertRecipients, criticalAlertConnectorName Yes Database overrides CRITICAL_ALERT_*
defaultApproverGroupId Yes No env equivalent — the database is the only source
allowedOrigins No CORS uses ALLOWED_ORIGINS; the field is echoed but never applied
rateLimitEnabled, rateLimitMax No Cosmetic — the limit is hardcoded
stuckRunTimeoutMinutes No STUCK_RUN_TIMEOUT_MINUTES only
dbPoolMax, dbPoolMin No DB_POOL_MAX / DB_POOL_MIN only

Variables that reliably confuse people

Variable Appears in Winner Why it bites
FLOH_RESOURCE_ID / OIDC_AUDIENCE .env.example, ci.env, the deploy's host env file FLOH_RESOURCE_ID if non-blank, else the deprecated OIDC_AUDIENCE Setting both to different values is a hard startup failure, not a warning. And the resolved value is not the audience the API verifies — see below
FLOH_CONSOLE_AUDIENCE / _PORTAL_ / _MCP_ ci.env, deploy variables the bundle These are the JWT aud allow-list. Startup fails when all three are empty
DB_PORT .env.example, base.env, local compose environment: local host-Node: your .env; in-container: compose Three layers with three different intentions — host publish port vs in-container listen port
DB_HOST / REDIS_HOST / SMTP_HOST base.env (Docker names), .env.example (localhost) your .env, only while base.env holds the Docker service name The override is value-conditional; see the sanitizer note above
NODE_ENV vs APP_ENV compose environment:, ci.env, Dockerfile.server APP_ENV, falling back to NODE_ENV Neither can be set from a public config file — both are read before the bundle loads. NODE_ENV simultaneously controls tier selection, bundle auto-discovery, and whether .env fallback works
VITEST test runner n/a VITEST=true disables the public bundle entirely. Copying it into your .env silently changes every precedence answer
AUTHIFI_VAULT_URL, _TENANT, _TENANT_ID, _CLIENT_ID, _KEY_FILE ci.env and ~/floh/.env process.env, so the host file The duplication is required, not redundant
AUTHIFI_VAULT_SECRET_PREFIX, AUTHIFI_VAULT_SCOPE nowhere — not wired process.env, which the deploy never populates Read by loadVaultConfigFromEnv but absent from ci.env and the workflow's write list, so setting either is inert and the defaults (FLOH_, and the least-privilege scope SECRETS_MANAGER.LIST SECRETS_MANAGER.PLAIN_SECRET) win silently — #1186

FLOH_RESOURCE_ID is not the audience the API verifies

This is worth stating plainly because several older documents claim otherwise. The API's accepted-audience allow-list is built from FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, and FLOH_MCP_AUDIENCE, and startup fails when all three are empty. FLOH_RESOURCE_ID (and its deprecated alias OIDC_AUDIENCE) is resolved into config but has no production consumer — its real job is operator tooling such as scripts/setup-authifi-oidc-clients.mjs. It is not a startup requirement.

Diagnosing a value that isn't taking effect

  1. Identify your runtime shape from the table at the top. If you are in local Docker Compose, no bundle is loaded and the answer is almost always your .env or the compose environment: block.
  2. Check the boot warnings — but know what they do not cover. The server warns about process.env keys it is ignoring, and that warning is useful on a deployed stack, but it fires only for keys absent from the bundle (!(key in publicConfig)) and only when a bundle is loaded with legacy fallback off. The shadowing case this page is mostly about — a key the bundle defines non-blank, like SHOW_ERROR_DETAILS, quietly beating your .env — produces no warning at all. Do not read a silent boot as confirmation that your value took effect.
  3. Grep the bundle for the key: rg '^<KEY>=' config/public/. A non-blank hit means the bundle wins and your .env is irrelevant. Because of the gap in step 2, this is the check that actually settles it.
  4. Check whether it is a bypass key (above). OTEL_* in particular looks configurable through ci.env and is not.
  5. Check whether it is a database setting (above).
  6. On a deployed stack, confirm what actually reached the container. For a non-secret key, print it:
compose exec server env | grep '^<KEY>='

For a secretDB_PASSWORD, JWT_SECRET, SESSION_SECRET, any SMTP or OIDC credential — never print the value. It would land in your scrollback and in any session recording or CI log. Check presence and length only:

compose exec server sh -lc 'printenv <KEY> | wc -c'

A count of 0 means unset, 1 means empty (just the newline), and anything larger confirms a value arrived without disclosing it.

These recipes answer for SECRETS_BACKEND=env only

printenv reports the container environment, which is always the GitHub copy — the deploy writes it to ~/floh/.env on both backends. Under SECRETS_BACKEND=authifi that is not the value the server uses: AuthifiSecretProvider fetches the effective secrets from the vault and caches them, with no fallback to process.env. So a check here can report match while the running server holds something else — precisely the state a cutover or a half-finished rotation produces, which is when you are most likely to be running the check.

There is no CLI for the vault side today, so verify a vault-backed secret in the Authifi admin UI or admin API against the FLOH_-prefixed name, and treat the container environment as evidence about the deploy, not about the server. DB_PASSWORD is the one key where both copies genuinely matter, for the reason given under rotation.

To check whether the running value matches the one you expect, compare inside the container and print only the verdict — never a digest:

compose exec server sh -lc '
  trap "stty echo" EXIT
  trap "stty echo; exit 130" INT
  trap "stty echo; exit 143" TERM
  stty -echo
  printf "expected value: " >&2
  IFS= read -r expected || { echo >&2; echo aborted >&2; exit 1; }
  stty echo; echo >&2
  [ "$expected" = "$(printenv <KEY>)" ] && echo match || echo differ
'

The prompt is unechoed, so the value you type reaches neither the terminal nor your shell history, and the comparison happens in the container against the live variable. POSIX sh has no read -s, hence the stty pair.

The traps are split deliberately. A single trap "stty echo" EXIT INT TERM restores echo but does not exit, and a handler that does not exit consumes the signal — so Ctrl-C at the prompt would leave the script running with expected empty and print differ, which reads as a real answer rather than an abort. The signal handlers therefore exit (130 for INT, 143 for TERM) while EXIT keeps doing cleanup. The read || branch covers Ctrl-D, which otherwise returns non-zero with an empty value and reaches the same misleading comparison.

They are also registered before stty -echo rather than after. In the other order there is a window where an interrupt exits with echo still disabled and the operator's terminal looks broken; registering first makes the worst case a redundant stty echo. And the read is IFS= read -r, because with the default IFS a secret configured with leading or trailing whitespace is silently trimmed and reported differ while being correct — the environment secret provider preserves the raw value, so that whitespace is real.

Do not publish a hash of the value, truncated or otherwise. A digest is a reusable offline verifier: anyone who later obtains it can test guesses against it at no cost, which for a database password, SMTP login, or client secret is often a short dictionary away. Truncation does not fix this — it only widens the set of candidates that match. Emit match / differ and keep both operands inside the trusted process.

Check BFF-owned secrets in their own container, under their mapped name. The OIDC client secrets never reach server — Compose feeds each one to its gateway as AUTH_CLIENT_SECRET — so inspecting server reports 0 for a perfectly healthy stack:

Secret Service Name inside the container
OIDC_CLIENT_SECRET console-bff AUTH_CLIENT_SECRET
PORTAL_OIDC_CLIENT_SECRET portal-bff AUTH_CLIENT_SECRET

The BFF images are distroless and ship no shell, so sh -lc fails there with an executable-not-found error rather than reporting the secret. Use the bundled Node binary, the same one the container's own healthcheck invokes:

compose exec console-bff /nodejs/bin/node -e \
  'const v=process.env.AUTH_CLIENT_SECRET; console.log(v?`present, ${v.length} chars`:"MISSING")'

To compare against an expected value without printing either, read it from stdin and emit only the verdict:

IFS= read -rs -p 'expected value: ' expected && echo &&
  printf %s "$expected" | compose exec -T console-bff /nodejs/bin/node -e \
    'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(d===process.env.AUTH_CLIENT_SECRET?"match":"differ"))'
unset expected

read -rs disables terminal echo for the duration of the read, so the value never reaches your scrollback or a session recording, and read restores echo itself rather than leaving the terminal in a broken state if you interrupt. Without it, compose exec -T leaves stdin attached to your terminal and renders every character you paste — which would defeat the whole point of comparing instead of printing.

The IFS= prefix and the absence of .trim() on the Node side are both there to keep the comparison byte-for-byte. Default IFS strips leading and trailing whitespace from what you type, and printf %s sends no trailing newline for a trim to remove, so either one can only discard bytes that belong to the secret — reporting differ for a value that is in fact correct.

The comparison is chained to the read with && on purpose. If they are separate lines and you abort the prompt with Ctrl-C, the remaining lines of the pasted block still execute, comparing an empty or stale expected and printing differ — which reads as a real answer rather than an abort. Chaining makes the abort propagate. unset expected stays unchained so it runs either way.

The same applies to the BFF cookie secrets and TLS PEMs, which live in env/console.env and env/portal.env — see the host-file table above.

The prompt recipes are for single-line secrets only

read -rs stops at the first newline, so pasting a PEM sends only -----BEGIN CERTIFICATE----- to the container. The comparison then reports differ for a perfectly correct certificate, and the remaining pasted lines land at your shell prompt as commands. Compare multiline values from a file instead, never from a paste:

umask 077
compose exec -T console-bff /nodejs/bin/node -e \
  'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(d===process.env.SERVER_CERT?"match":"differ"))' \
  < expected.pem

The BFF containers hold the PEMs as SERVER_CERT and SERVER_KEY, not under the CONSOLE_BFF_TLS_* / PORTAL_BFF_TLS_* names the host env files use — Compose renames them on the way in. Checking the host-side name inside the container reports differ for a correct certificate.

Note this variant compares the bytes exactly rather than trimming, since trailing-newline differences are a real cause of PEM load failures and you want to see them.

See Running compose commands on the host — the flags matter.

Editing configuration: who changes what, where

To change… Edit Takes effect
A secret on a deployed stack, SECRETS_BACKEND=env GitHub dev environment → secrets Next deploy run
An API secret when SECRETS_BACKEND=authifi The Authifi vault — see Where secrets live Next server restart
A non-secret runtime value on a deployed stack config/public/ci.env — unless the key is in Layers that bypass the rules, which need ~/floh/.env Next deploy after the commit merges
A deploy input scoped to the environment (the domains, AUTHIFI_ADMIN_TARGET) GitHub dev environment → variables Next deploy run
DEPLOY_FORM_BUILDER_DOMAIN GitHub repository → variables. The build guard fails with an explicit error if it is set only on the environment, because the web image bakes the value in at build time Next deploy run
The image prefix Nothing — IMAGE_PREFIX is derived from the lowercased GITHUB_REPOSITORY in both jobs and is not configurable. A dev variable of that name is ignored n/a
A local development value Your .env Restart
A default for all contributors config/public/base.env Merge, then everyone's next restart
Critical alerts or the default approver group Admin → Settings in the console Immediately

Note the second row: because ci.env outranks the host env file for non-secrets, changing a non-secret in the GitHub environment usually does nothing. That is the intended design — non-secrets are reviewed in git — but it surprises operators who expect the environment page to be authoritative.

The exception runs the other way. Keys listed under Layers that bypass the rules are read straight from process.env and never consult the bundle, so putting one in ci.env is silently inert: the commit merges, the deploy goes green, and the value has no effect. They split into two groups, and the difference matters:

  • Wired. The vault URL, tenant, tenant id, client id, and key-file locators are bound by the deploy workflow and written into ~/floh/.env, so they have a durable path.
  • Not wired. WORKER_MODE, CONNECTOR_DEBUG, WEBHOOK_SIG_FAILURE_THRESHOLD, WEBHOOK_SIG_FAILURE_WINDOW_MS, DEPLOYMENT_ENVIRONMENT, CONNECTOR_MOCK_MODE, every CRITICAL_ALERT_* key (_ENABLED, _RECIPIENTS, _CONNECTOR_NAME, _COOLDOWN_MS), ENABLE_TEST_SUPPORT_ROUTES, TEST_SUPPORT_SECRET, and every OTEL_* key appear nowhere in .github/workflows/deploy.yml or docker/docker-compose.deploy.yml. To regenerate this list, grep process.env. under all of packages/server/src — not just modules/, since WORKER_MODE and a second copy of the CRITICAL_ALERT_* reads live in app.ts — and check each name against those two files. The OTEL_* keys will not appear at all: the OpenTelemetry SDK parses its own environment, so there is no process.env.OTEL_* literal to find. Add them from the SDK's documented variables.

CONNECTOR_MOCK_MODE is the one with teeth: shouldUseMock() reads it straight from process.env, so putting it in ci.env to stand up a mocked test stack leaves mocks off and lets workflow steps reach real external systems. The CRITICAL_ALERT_* group is the quiet one — a deployment that believes it has configured alerting has not. DEPLOYMENT_ENVIRONMENT is the non-OTEL_* one that is easy to miss: observability/config.ts falls back to NODE_ENV when it is absent, so a deployment that wants a telemetry label like staging cannot express it and every environment reports as production. AUTHIFI_VAULT_SECRET_PREFIX and AUTHIFI_VAULT_SCOPE belong here too: loadVaultConfigFromEnv reads both from process.env, but the workflow copies neither into the host env file, so a deployment needing a non-default secret prefix or OAuth scope cannot express it and silently gets the defaults. Wiring the remaining bypass keys is tracked in #1186. There is currently no supported way to set them on a deployed stack: ci.env is inert for them, and editing ~/floh/.env by hand does not survive, because the workflow rewrites that file atomically on every deploy. Treat them as fixed at their defaults in a deployed environment until #1186 wires them up.

  • Deployment Guide — first-time bring-up, secrets and variables tables, and the failure reference
  • Developer Guide — local setup
  • config/public/development.env — the header comment is the most detailed in-repo description of the bundle mechanism