Skip to main content

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

  1. When interpolation runsat 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 need ConfigSource.watch.

  2. 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
    config.getInt("database.pool_size") # 20 — coerced on the fly
    config.getString("database.pool_size") # "20" — stays a string
  3. 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).
  4. 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.

  5. 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):

.env (gitignored)
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.

app-config.yaml (base)
database:
password: "${DB_PASSWORD}"
app-config.production.yaml
database:
password: "${DB_PASSWORD_PRODUCTION}" # a separate env variable in prod

For DAGSTACK_ENV=production:

  1. The base layer is loaded → ${DB_PASSWORD}"local-pw" from the environment.
  2. The production layer is loaded → ${DB_PASSWORD_PRODUCTION}"prod-pw".
  3. 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