Environment variable substitution
Values in a YAML configuration can reference environment variables using the ${...} syntax. Interpolation runs at file load time, before layers are merged and before typed access.
Syntax
${VAR} → value of the env variable VAR; if unset, ConfigError
${VAR:-default} → value of VAR, or the literal "default" if VAR is unset / empty
Examples:
database:
host: "${DB_HOST:-localhost}"
port: "${DB_PORT:-5432}"
password: "${DB_PASSWORD}"
pool_size: "${DB_POOL_SIZE:-20}" # still a string; type coercion happens later
cache:
url: "${REDIS_URL:-redis://localhost:6379/0}"
Semantics
-
When interpolation runs — at file load time (not on every read). If an env variable changes after
Config.load(), the config is not refreshed automatically; for hot reload you needConfigSource.watch. -
The result is always a string —
${NUM}produces a string. Type coercion (getInt,getBool) happens at read time, not during interpolation:database:pool_size: "${DB_POOL_SIZE:-20}" # the string "20" after interpolation- Python
- TypeScript
- Go
config.getInt("database.pool_size") # 20 — coerced on the flyconfig.getString("database.pool_size") # "20" — stays a stringconfig.getInt("database.pool_size"); // 20config.getString("database.pool_size"); // "20"cfg.GetInt("database.pool_size") // 20cfg.GetString("database.pool_size") // "20" -
Type coercion rules (strict):
int:^-?\d+$.bool:true|false|yes|no|1|0(case-insensitive).float: standard floating-point parser.- If the string does not match the type →
ConfigError(type_mismatch).
-
Default values are literal strings — nested
${...}inside the default part is not interpolated. This keeps the parser simple:database:# OK:host: "${DB_HOST:-localhost}"# Does not work (the default is a literal):password: "${DB_PASSWORD:-${FALLBACK_PASSWORD}}" # the default will be the literal string "${FALLBACK_PASSWORD}"If you need a two-stage fallback, use an external script / shell-exec, or handle it in application code.
-
Escape a literal
$—$$→$. Useful when you literally need a$character:billing:price_prefix: "$$" # literal "$"
Missing env without a default
database:
password: "${DB_PASSWORD}" # no default
If DB_PASSWORD is unset in the environment, Config.load raises ConfigError:
ConfigError(
path="database.password",
reason="env_unresolved",
details="Environment variable DB_PASSWORD is not set and no default provided",
)
The application gets an explicit error at startup — the password is not silently substituted with an empty string, which would lead to a runtime "authentication failed" surprise.
Where to set env variables
Local development: an .env file picked up by a tool (direnv / dotenv):
DB_HOST=localhost
DB_PASSWORD=local-dev-pw
REDIS_URL=redis://localhost:6379/0
DAGSTACK_ENV=
Docker / production: through env: in docker-compose.yml / kubernetes/deployment.yaml.
CI: through the secrets mechanism of your CI system (GitHub Actions Secrets / Gitea Secrets).
dagstack/config does not read .env files itself — that is the responsibility of whoever launches the process (Python python-dotenv, Node dotenv, direnv, and so on). By the time the application starts, the variables are already available in os.environ / process.env.
Substitution across layers
Env interpolation runs independently for each file layer. The result: different layers can reference different env variables, and merge operates on already-interpolated values.
database:
password: "${DB_PASSWORD}"
database:
password: "${DB_PASSWORD_PRODUCTION}" # a separate env variable in prod
For DAGSTACK_ENV=production:
- The base layer is loaded →
${DB_PASSWORD}→"local-pw"from the environment. - The production layer is loaded →
${DB_PASSWORD_PRODUCTION}→"prod-pw". - Merge: the production value wins →
password: "prod-pw".
Common anti-patterns
# ✗ Too much magic — the application depends on specific env vars:
feature_flags:
new_ui_enabled: "${NEW_UI_ENABLED:-true}" # why an env override for every flag?
# ✓ Better — base value in YAML, override through app-config.${ENV}.yaml:
feature_flags:
new_ui_enabled: true
# ✗ A secret with a placeholder default:
database:
password: "${DB_PASSWORD:-admin123}" # in prod without env, the default kicks in (dangerous!)
# ✓ No default — an explicit error at startup if you forgot the env:
database:
password: "${DB_PASSWORD}"
# ✗ Default contains an env variable (it will not be interpolated):
database:
url: "${DATABASE_URL:-postgresql://user:${DB_PASSWORD}@localhost/dev}"
# ✓ Use a separate env var or build it in application code:
database:
url: "${DATABASE_URL}"
# Assemble the URL from parts in the application, not in YAML.
See also
- Secrets — env as the primary channel for secrets.
- Configuration layers — env substitution inside each layer.
- ADR-0001 §2 Env interpolation syntax — the normative contract.