# Environment Variables

A deep-scan inventory of every environment variable ScadaBridge reads — the Host, the CLI, the
DelmiaNotifier client, EF design-time tooling, and the test/CI harness — plus the supporting infra
containers. Compiled by scanning `Environment.GetEnvironmentVariable` call sites, the Host's
`AddEnvironmentVariables()` config source, `docker/` + `docker-env2/` compose files,
`deploy/wonder-app-vd03/install.ps1`, and every `appsettings*.json`.

**Scan date:** 2026-07-24 · **Branch:** `main`

Two columns qualify each variable:

- **Scope** — where it is read: **Runtime** (the running product — Host / CLI / DelmiaNotifier),
  **Design-time** (`dotnet ef` tooling), **Test** (unit / integration / E2E / perf harness only, never
  the shipped product), or **Infra** (a sidecar container, not ScadaBridge code).
- **In appsettings?** — whether a shipped `appsettings*.json` already carries the same config key
  (so the env var is an *override* or *secret substitute*), versus being env-only. `${secret:...}`
  means the key is present but its value is a secret token, not a literal.

## How configuration resolves (read this first)

The Host builds its configuration in `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` in this precedence
order (later overrides earlier):

1. `appsettings.json`
2. `appsettings.{SCADABRIDGE_CONFIG}.json` (role file — `Central` or `Site`)
3. **`.AddEnvironmentVariables()`** ← every variable below in the "config-override" table
4. `.AddCommandLine(args)`

Then a **pre-host secrets expander** resolves any `${secret:NAME}` tokens left in the merged config,
reading the encrypted secrets store whose master key comes from **`ZB_SECRETS_MASTER_KEY`**. Because
step 3 runs *before* expansion, a whole-key env override (e.g. `ScadaBridge__Database__ConfigurationDb`)
wins over — and short-circuits — the corresponding `${secret:...}` token. That is the deliberate
"rollback / dev" path the docker rigs use.

**Config-key → env-var mapping:** replace each `:` in a config path with `__` (double underscore).
So `ScadaBridge:Communication:GrpcPsk` is set via `ScadaBridge__Communication__GrpcPsk`. Any config
key in the app can be overridden this way; the table below lists only the ones with an established
purpose (secrets kept off disk, or dev-rig wiring).

---

## 1. Role & host environment selection

| Variable | Scope | In appsettings? | Consumed by | Purpose | Potential values | Required |
|---|---|---|---|---|---|---|
| `SCADABRIDGE_CONFIG` | Runtime | No (it *selects* the `appsettings.{value}.json` file) | Host (`Program.cs:42`) | Selects the role-specific overlay. **Primary role switch.** | `Central`, `Site` | Effectively yes (falls back to `DOTNET_ENVIRONMENT`, then `Production`) |
| `DOTNET_ENVIRONMENT` | Runtime | No | Host (`Program.cs:43`) | Fallback role selector when `SCADABRIDGE_CONFIG` is unset; generic .NET environment name. | `Central`, `Site`, `Development`, `Production` | No |
| `ASPNETCORE_ENVIRONMENT` | Runtime | No | ASP.NET Core + `DisableLoginGuard` (`Program.cs:438`) | Standard ASP.NET environment. `DisableLogin` is refused unless this is `Development`. | `Development`, `Production` | No (defaults to `Production` behavior) |
| `ASPNETCORE_URLS` | Runtime | No (Kestrel binding; not in these appsettings) | Kestrel | HTTP bind addresses. Docker sets `http://+:5000`; install.ps1 sets `http://+:{WebPort}` (8085). | e.g. `http://+:5000`, `http://+:8085` | No |

> `SCADABRIDGE_CONFIG` and `ASPNETCORE_ENVIRONMENT` are independent. The docker rig runs
> `SCADABRIDGE_CONFIG=Central` **and** `ASPNETCORE_ENVIRONMENT=Development` simultaneously.

## 2. Secrets store master key (KEK)

| Variable | Scope | In appsettings? | Consumed by | Purpose | Potential values | Required |
|---|---|---|---|---|---|---|
| `ZB_SECRETS_MASTER_KEY` | Runtime | Name only (`Secrets:MasterKey:EnvVarName` in `appsettings.json`; the value is never committed) | Host secrets expander | The KEK that decrypts the `ZB.MOM.WW.Secrets` store, from which every `${secret:...}` token resolves. A missing reference fails closed (`SecretNotFoundException`) before any SQL/LDAP/cluster wiring. | an opaque base64/hex master key | Yes on any node whose appsettings uses `${secret:...}` (shipped Central + Site do). Docker rigs avoid it with whole-key overrides. |

The variable *name* is itself configurable via `Secrets:MasterKey:EnvVarName` (defaults to
`ZB_SECRETS_MASTER_KEY`). See `docs/operations/2026-07-16-secrets-clustered-master-key.md`.

## 3. Config-override env vars (`ScadaBridge__*` whole-key)

Map onto config keys via the `:`→`__` rule. Keep secrets off disk (production, via a secret manager)
or wire the dev rigs without a KEK. The `__site-x` / `__site-a` leaf is the literal `siteId`.
All are **Runtime** scope (the Host reads them; Host integration-test fixtures set the same names, but
that is the same variable, not a separate one).

| Variable | In appsettings? | Config key | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `ScadaBridge__Database__ConfigurationDb` | Yes — `${secret:sql/scadabridge/configdb-connection}` in `appsettings.Central.json` | `ScadaBridge:Database:ConfigurationDb` | Central config DB connection string (MS SQL). Central-only. | `Server=host,1433;Database=ScadaBridgeConfig;User Id=...;Password=...;TrustServerCertificate=true` | Yes on Central — via env **or** the `${secret}` |
| `ScadaBridge__Database__MachineDataDb` | No in shipped src appsettings; present in the deploy overlay (`deploy/wonder-app-vd03/appsettings.Central.json`, as `${...}`). install.ps1 derives it as `<DbName>MachineData`. | `ScadaBridge:Database:MachineDataDb` | Central machine-data DB connection string. Central-only. | connection string | Yes on Central |
| `ScadaBridge__Security__JwtSigningKey` | Yes — `${secret:security/scadabridge/jwt-signing-key}` in `appsettings.Central.json` | `ScadaBridge:Security:JwtSigningKey` | HMAC-SHA256 signing key for the cookie-embedded session JWT. | ≥32-char secret | Yes on Central |
| `ScadaBridge__Security__Ldap__ServiceAccountPassword` | Yes — `${secret:ldap/scadabridge/service-account-password}` in `appsettings.Central.json` | `ScadaBridge:Security:Ldap:ServiceAccountPassword` | LDAP bind-account password. (Renamed from the old flat `...__LdapServiceAccountPassword`.) | the bind password | Yes on Central if LDAP bind is used |
| `ScadaBridge__InboundApi__ApiKeyPepper` | No — env/secret only (not in any shipped appsettings) | `ScadaBridge:InboundApi:ApiKeyPepper` | Per-environment pepper for the inbound API-key peppered-HMAC verifier. Hard Central startup requirement since 2026-06-03. | ≥16-char secret, unique per environment | Yes on Central |
| `ScadaBridge__Communication__GrpcPsk` | Yes — `${secret:SB-GRPC-PSK-site-1}` in `appsettings.Site.json` | `ScadaBridge:Communication:GrpcPsk` | **Site side** of the per-site gRPC control-plane PSK. StartupValidator refuses to boot a Site node without it; both pair nodes carry the same value. | per-site secret (e.g. `dev-grpc-psk-docker-site-a`); prod `${secret:SB-GRPC-PSK-<siteId>}` | Yes on Site |
| `ScadaBridge__Communication__SitePsks__<siteId>` | No in shipped src appsettings (central normally resolves the `SB-GRPC-PSK-<siteId>` secret); present only in the docker-compose env blocks | `ScadaBridge:Communication:SitePsks:<siteId>` | **Central side** of the same key: the value central presents when dialling that site. Must equal the site's `GrpcPsk`. Override for hosts without a master secret store. | matches the site's `GrpcPsk` | On Central, either this **or** the `SB-GRPC-PSK-<siteId>` store secret |

Docker dev values (dev-only-insecure, committed in `docker/docker-compose.yml`):
`ScadaBridge__Communication__SitePsks__site-a = dev-grpc-psk-docker-site-a` (also `site-b`, `site-c`);
`docker-env2` uses `SitePsks__site-x = dev-grpc-psk-docker-env2-site-x`.

> Any other config key is overridable the same way (e.g. `ScadaBridge__Cluster__SplitBrainResolverStrategy`,
> `ScadaBridge__Logging__MinimumLevel`) — these are the standard .NET config binder, not "special" env
> vars. Prefer `appsettings` files for non-secret values.

### `${secret:...}` tokens (resolved from the store, NOT env vars)

Not environment variables — config values resolved by the secrets expander using `ZB_SECRETS_MASTER_KEY`.
All are **Runtime** scope and, by definition, present **In appsettings** as tokens.

| Token (in `appsettings`) | Meaning |
|---|---|
| `${secret:sql/scadabridge/configdb-connection}` | Central config DB connection string |
| `${secret:ldap/scadabridge/service-account-password}` | LDAP bind password |
| `${secret:security/scadabridge/jwt-signing-key}` | JWT signing key |
| `${secret:SB-GRPC-PSK-<siteId>}` | Per-site gRPC PSK (site side); central resolves `SB-GRPC-PSK-{siteId}` at channel-build time |

## 4. CLI (`scadabridge` / `ZB.MOM.WW.ScadaBridge.CLI`)

Resolution order per value: command-line flag → env var → `~/.scadabridge/config.json`. The CLI does
**not** read `appsettings.json` — its config file is `~/.scadabridge/config.json` (a separate schema).

| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `SCADABRIDGE_MANAGEMENT_URL` | Runtime (CLI) | No — CLI config file has `managementUrl` instead | Management API base URL. Overridden by `--url`; overrides the config file. Missing all three → exit `1`, `NO_URL`. | e.g. `http://localhost:9000` | One of flag/env/config |
| `SCADABRIDGE_FORMAT` | Runtime (CLI) | No — CLI config file has `defaultFormat` instead | Default output format. Overridden by `--format`. | `json`, `table` | No (defaults to `json`) |
| `SCADABRIDGE_USERNAME` | Runtime (CLI) | No | LDAP username fallback when `--username` absent. | e.g. `multi-role` | No (some commands need auth) |
| `SCADABRIDGE_PASSWORD` | Runtime (CLI) | No | LDAP password fallback when `--password` absent. **Preferred over `--password`** — avoids leaking into process listings / shell history. | the user's password | No (some commands need auth) |

## 5. DelmiaNotifier client (`WWNotifier.exe`)

| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `SCADABRIDGE_API_KEY` | Runtime (client) | No — the notifier's `appsettings.json` holds only `BaseUrls`/`TimeoutSeconds`/`LogPath`; the key is **env-only by design** | Inbound API key sent as `X-API-Key`. Read only from the environment, never a file. Missing/empty → prints `API key not configured (SCADABRIDGE_API_KEY)`, exit `-1`, no HTTP attempt. | e.g. `sbk_...` | Yes |

> This is also why `System.Environment` is wholly forbidden in the script trust model — it would let a
> site/inbound script exfiltrate `SCADABRIDGE_API_KEY` and other host env secrets.

## 6. EF Core design-time tooling

| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING` | Design-time | Fallback for `ScadaBridge:Database:ConfigurationDb` — the factory reads that appsettings key first, then this env var | Connection string for `dotnet ef` migrations tooling when the Host's appsettings isn't the source. No hardcoded fallback — missing → actionable failure. | a config DB connection string | Only for `dotnet ef` when appsettings is unavailable |

## 7. Test / CI-only

Not read by the running product — the test harness only. Most default gracefully (skip, or use a
local docker value) when unset.

| Variable | Scope | In appsettings? | Consumed by | Purpose | Potential values |
|---|---|---|---|---|---|
| `SCADABRIDGE_MSSQL_TEST_CONN` | Test | No | `MsSqlMigrationFixture`, AuditLog migration tests | Admin (sa) connection to a running MS SQL; absent → local docker sa default; drives `Skip.If`. | sa connection string |
| `SCADABRIDGE_PLAYWRIGHT_DB` | Test | No | `PlaywrightDbConnection` | DB connection for Playwright E2E seeding; absent → `localhost:1433`. | connection string |
| `SCADABRIDGE_CLI_DLL` | Test | No | `CliRunner` | Absolute path to the built CLI dll for Playwright when auto-probing fails. | path to `...CLI.dll` |
| `SCADABRIDGE_AUDIT_FILTER_4KB_P95_US` | Test | No | `HotPathLatencyTests` | Override P95 latency threshold (µs) for the 4 KB audit-filter perf test. | positive number (default 5) |
| `SCADABRIDGE_AUDIT_FILTER_RAW_P95_US` | Test | No | `HotPathLatencyTests` | Override P95 threshold for the raw-capture perf test. | positive number |
| `SCADABRIDGE_AUDIT_FILTER_NOOP_P95_US` | Test | No | `HotPathLatencyTests` | Override P95 threshold for the no-op fast-path perf test. | positive number (default 5) |
| `HOME` / `USERPROFILE` | Test | No | `CliConfig` tests | Redirected to locate `~/.scadabridge/config.json` under test. | temp dir path |

The Host test fixtures (`CentralDbTestEnvironment`, `ScadaBridgeWebApplicationFactory`,
`StartupValidationTests`) set the **section-3 config-override vars** (`ScadaBridge__Database__*`,
`ScadaBridge__InboundApi__ApiKeyPepper`, etc.) and `DOTNET_ENVIRONMENT=Central` at runtime; those are
the same variables already documented above, not new ones.

## 8. Supporting infrastructure containers (not the app)

Set on the sidecar/infra containers in `infra/docker-compose.yml`, consumed by those images, not by
ScadaBridge code — included for completeness when standing up a rig.

| Variable | Scope | In appsettings? | Container | Purpose | Value used |
|---|---|---|---|---|---|
| `MSSQL_SA_PASSWORD` | Infra | No | `scadabridge-mssql` | SQL Server `sa` password. | `ScadaBridge_Dev1#` (dev-only) |
| `MSSQL_PID` | Infra | No | `scadabridge-mssql` | SQL Server edition. | `Developer` |

---

## Quick reference: minimum sets to boot a real deploy

**Central node** (via env, or the `${secret:...}` equivalents + `ZB_SECRETS_MASTER_KEY`):
`SCADABRIDGE_CONFIG=Central`, `ScadaBridge__Database__ConfigurationDb`,
`ScadaBridge__Database__MachineDataDb`, `ScadaBridge__Security__JwtSigningKey`,
`ScadaBridge__Security__Ldap__ServiceAccountPassword`, `ScadaBridge__InboundApi__ApiKeyPepper`, and
for each managed site either the `SB-GRPC-PSK-<siteId>` secret or `ScadaBridge__Communication__SitePsks__<siteId>`.

**Site node:** `SCADABRIDGE_CONFIG=Site` and `ScadaBridge__Communication__GrpcPsk` (or its
`${secret:SB-GRPC-PSK-<siteId>}` form). StartupValidator hard-fails without the PSK.
