Environment Configuration (envdef)¶
Under the ENV Management Service architecture, each service owns the shape of its environment configuration in its own repository as a local definition file (envdef.yaml). The service never resolves this file itself: it sends the raw definition to the ENV Management Service, which overlays deployment context, resolves secret references through the configured secret provider, applies optional central overrides, and returns a fully resolved environment map.
This page defines the local def file, its reference syntax, and the configuration precedence. For the key-naming rules (the AC_ prefix, DOMAIN_TYPE component names, standard keywords) see Environment Variables.
The envdef.yaml File¶
Location¶
envdef.yaml lives in the service's project root, next to appsettings.json (the place a developer instinctively looks for configuration). It is versioned with the service and ships in the container image.
src/Store.Api/
appsettings.json # static, deployment-invariant defaults
envdef.yaml # env definition (this file)
Program.cs
Store.Api.csproj
An optional per-environment overlay envdef.{Environment}.yaml (e.g. envdef.Production.yaml) sits beside the base file. {Environment} matches AC_ENVIRONMENT (Development, Testing, Staging, Production).
The same file, in the same location, is used by Node.js services and is read by the ENV Agent for third-party components (Keycloak, etc.). The format is language-agnostic on purpose.
Root Structure¶
The file is a flat key -> value map. The value's syntax alone determines its source class (static / template / secret) - there is no separate type field, which keeps the file terse and hand-editable.
# envdef.yaml
version: 1
service: store-api # component identity; default namespace for ${secret:./...}
def:
# static - literal, no interpolation. Own properties: no component (see Environment Variables)
AC_ENVIRONMENT: Production
AC_PORT: 8080
AC_CACHE_ENABLED: true
# template - references deployment context
AC_EXTERNAL_URL: '${context:root_external}/store/api'
AC_INTERNAL_URL: '${context:cluster_domain}/store'
# secret ref - resolved by the secret provider (OpenBao). The secret path stays
# service-scoped for authorization even though the variable name carries no component
AC_DB_CONNECTION_STRING: '${secret:store-api/db_connection}'
AC_JWT_SIGNING_KEY: '${secret:./jwt_signing_key}' # ./ = this service
# composition - reference another resolved AC_ key
AC_HEALTH_URL: '${env:AC_INTERNAL_URL}/health'
# cross-cutting dependency - component names the dependency, not this service, scheme-less by convention (see Kafka's own bootstrap.servers)
AC_KAFKA_BOOTSTRAP_SERVERS: '${context:kafka_bootstrap_servers}'
Root fields:
| Field | Required | Meaning |
|---|---|---|
version |
yes | Schema version, for forward compatibility. Currently 1. |
service |
yes | The service/component identity. Used as the default secret namespace, so ${secret:./key} means "this service". |
def |
yes | The map of AC_ keys to their values or references. |
An overlay file (envdef.{Environment}.yaml) has the same shape and is merged key by key: a key present in the overlay replaces the base value; keys not in the overlay are inherited.
Reference Syntax¶
Any value that is not a plain literal uses the ${...} interpolation form. The ${...} form is chosen over a bare {...} because a plain YAML scalar starting with { is parsed as a flow mapping (a parse error), while ${...} starts with $ and is safe; it is also familiar from shell.
References are namespaced. The namespace is not just routing - it encodes the resolution backend, the sensitivity of the value (whether it must be masked in logs and reports), the authorization scope, and the audit source class. Do not collapse the namespaces into one keyword.
| Reference | Resolves from | Sensitive | Example |
|---|---|---|---|
${secret:<service>/<path>} |
The configured secret provider (OpenBao by default) | Yes - masked | ${secret:store-api/db_connection} |
${context:<name>} |
Per-install deployment context (globals set once per deployment) | No | ${context:rabbitmq_url} |
${env:<AC_KEY>} |
Another already-resolved AC_ key in the same resolution |
Inherits | ${env:AC_INTERNAL_URL} |
Secret references¶
Secret references are path-structured: ${secret:<service>/<path>}. The first segment is the service and acts as the authorization boundary - it maps directly to a secret-provider path and a per-service least-privilege policy. The rest is the secret path within that service's namespace and may be nested.
AC_DB_CONNECTION_STRING: '${secret:store-api/db_connection}'
AC_DB_PASSWORD: '${secret:store-api/db/password}' # nested path
AC_JWT_SIGNING_KEY: '${secret:./jwt_signing_key}' # ./ = current service (root `service:`)
The variable name carries no component (these are the service's own secrets), but the secret path's first segment is always the real service identity — that's the authorization boundary, and it doesn't change based on how the variable is named.
${secret:store-api/db_connection} maps to the OpenBao path secret/data/store-api, field db_connection, governed by a policy such as:
path "secret/data/store-api/*" { capabilities = ["read"] }
A flat, underscore-joined form (${secret:store_api_db_connection}) is invalid: it is ambiguous about where the service name ends and it provides no authorization boundary.
Context references¶
Context values are per-install deployment globals (the ingress root, the cluster domain, shared infrastructure endpoints). They have no service dimension and no authorization scope, so they use a single flat namespace: ${context:<name>}. The set of available context names is defined and owned by the Platform team (see the well-known context values reference); a service cannot invent a new context name - adding one is a platform change.
AC_EXTERNAL_URL: '${context:root_external}/store/api'
AC_EVENT_BUS_URI: '${context:rabbitmq_url}'
If a value carries credentials, it is a secret, not a context value.
Naming inside references¶
- Names inside
secret/contextuse lowercasesnake_case:db_connection,rabbitmq_url,root_external. - The service segment of a secret reference matches the service identity (lowercase, aligned with the deployment/Kubernetes name):
store-api.
Escaping¶
Two escaping layers are kept separate so they never interfere:
- YAML layer. Quote every value that contains
${...}with single quotes. In single-quoted YAML the backslash is literal and only''escapes a quote, so the template syntax is passed through untouched. Rule: any line with${...}is single-quoted. - Template layer. To emit a literal
$, write$$; the engine collapses$$to$. This matches Docker Compose.
AC_PROMO_LABEL: 'Save $$5 today' # resolves to: Save $5 today
YAML type pitfalls
YAML infers scalar types (the "Norway problem": NO becomes false; a leading-zero number like 08 is an octal error). The resolved output coerces every value to a string, but to avoid surprises in the source, quote ambiguous string values (country codes, versions, digit-only strings). Plain integers and booleans whose intent is obvious (8080, true) may stay unquoted.
Configuration Precedence¶
For AC_-prefixed keys, the ENV definition is authoritative. Resolved envdef values override appsettings.json, and even override a raw environment variable of the same name. This is deliberate:
- Parity. The ENV Management Service must resolve the same input to the same output on every deployment target (Kubernetes/OpenShift, Podman/Docker, local). Letting an arbitrary environment variable silently override
envdefbreaks determinism. - Security. A stale or injected
AC_environment variable must not silently shadow a vault-resolved secret. - Explicit overrides. A developer overrides a value by editing the versioned
envdef.Development.yaml, not by exporting a raw variable - the override is visible and tracked, not invisible shell state.
Precedence for AC_ keys, highest wins:
envdef.{Environment}.yaml(resolved)envdef.yaml(resolved)- Real OS environment variables (for non-
AC_keys only; in local dev these come fromlaunchSettings.json)
appsettings.json and appsettings.{Environment}.json no longer hold AC_ values - anything environment- or install-specific (URLs, ports, addresses, secrets) moves to envdef.yaml. appsettings.json keeps only static, deployment-invariant defaults.
Non-AC_ framework/OS keys (DOTNET_*, PATH, ...) follow the standard .NET rules unchanged; envdef does not touch them. ASPNETCORE_ENVIRONMENT is not one of these - it is derived from AC_ENVIRONMENT, not set independently (see below).
AC_ENVIRONMENT is the one AC_ key excluded from the precedence rule above: it must exist as a real, bootstrap-time OS environment variable, because it is what selects which envdef.{Environment}.yaml overlay to load - it cannot itself come from a file it helps select. It is the single source of truth for "which environment is this" across every service and language in a deployment.
The role of launchSettings.json¶
launchSettings.json no longer overrides AC_ values. Its only role is to set AC_ENVIRONMENT, which selects the envdef.{Environment}.yaml overlay.
envdef is language-agnostic (Node.js services and the ENV Agent read the same file format and have no ASP.NET concept at all), so overlay selection is keyed off AC_ENVIRONMENT, not a framework-specific variable. In a .NET service, host startup reads AC_ENVIRONMENT and sets ASPNETCORE_ENVIRONMENT to the same value before appsettings.{Environment}.json is loaded - the framework's environment selection follows AC_ENVIRONMENT automatically instead of being configured separately.
Value Source Classification¶
Each AC_ key's source class is inferred from its value syntax, which is what the audit and the ENV Management Service use - no separate declaration is needed:
| Class | Value syntax | Example |
|---|---|---|
static |
Plain literal | AC_PORT: 8080 |
template |
Contains ${context:...} (deployment context) |
'${context:root_external}/store/api' |
secret ref |
Contains ${secret:...} |
'${secret:store-api/db_connection}' |
deployment context |
A per-install global consumed by templates | ${context:rabbitmq_url} |
Authoring Checklist¶
Before adding a value to envdef.yaml, verify that:
- The key follows the naming convention (
AC_prefix, correct suffix, and a component only when the value is about a different system — never this one). - A sensitive value uses
${secret:<service>/<path>}, never a literal. - A per-install value uses
${context:<name>}from the well-known set, not a hardcoded address. - Any value containing
${...}is single-quoted; a literal$is written as$$. - Environment- or install-specific values live here, not in
appsettings.json.