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:
- If the public bundle defines the key with a non-blank value, that value
wins — immediately, without consulting
process.envat all. No local.enventry, no exported shell variable, and no host~/floh/.envline can override it. - If the bundle defines the key but leaves it blank, or omits it entirely,
process.envsupplies the value — but only when legacy-env fallback is enabled. - Legacy-env fallback is enabled when
NODE_ENV=development, whenNODE_ENV=testwithAPP_ENV=development, or whenALLOW_LEGACY_ENV_NON_SECRET=true. On a deployed stackci.envsetsNODE_ENV=production, so none of these apply and a key the bundle omits resolves toundefinedrather 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_KEYare not read from your.envin local development. The launcher reads the certificate files and injects the PEMs into the spawned process itself.PORTAL_PORTandPORTAL_HOSTare declared inbase.envbut nothing reads them. The BFF listen port isSERVER_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/.envdoes 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 composecall needs all three--env-fileflags, 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¶
- 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
.envor the composeenvironment:block. - Check the boot warnings — but know what they do not cover. The server
warns about
process.envkeys 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, likeSHOW_ERROR_DETAILS, quietly beating your.env— produces no warning at all. Do not read a silent boot as confirmation that your value took effect. - Grep the bundle for the key:
rg '^<KEY>=' config/public/. A non-blank hit means the bundle wins and your.envis irrelevant. Because of the gap in step 2, this is the check that actually settles it. - Check whether it is a bypass key (above).
OTEL_*in particular looks configurable throughci.envand is not. - Check whether it is a database setting (above).
- On a deployed stack, confirm what actually reached the container. For a non-secret key, print it:
For a secret — DB_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:
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, everyCRITICAL_ALERT_*key (_ENABLED,_RECIPIENTS,_CONNECTOR_NAME,_COOLDOWN_MS),ENABLE_TEST_SUPPORT_ROUTES,TEST_SUPPORT_SECRET, and everyOTEL_*key appear nowhere in.github/workflows/deploy.ymlordocker/docker-compose.deploy.yml. To regenerate this list, grepprocess.env.under all ofpackages/server/src— not justmodules/, sinceWORKER_MODEand a second copy of theCRITICAL_ALERT_*reads live inapp.ts— and check each name against those two files. TheOTEL_*keys will not appear at all: the OpenTelemetry SDK parses its own environment, so there is noprocess.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.
Related¶
- 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