ADR-012: Layered YAML Configuration with Environment Overrides

On this page

Context

Canopy services currently configure themselves exclusively through environment variables in the CANOPY_{SERVICE}{SETTING} convention, parsed by the config crate with Environment::with_prefix(…​).separator("") at crates/canopy-common/src/settings.rs:109.

Current surface (as of 2026-04-23):

  • docker-compose.yml — 208 CANOPY_* env var references

  • .env.example — 30 documented keys

  • Service source — ~110 std::env::var("CANOPY_*") / settings-field reads across 19 services

The env-var-only approach has four concrete problems:

  1. No schema validation. A typo like CANOPY_SNAP__GROSS_INCOME_CEILLING silently deserialises to None / default; the service starts and runs with wrong config until a user observes the behaviour. Struct deserialization catches the typo only if the field is #[serde(deny_unknown_fields)], which is not applied consistently.

  2. Ambient schema across services. docker-compose.yml is the de-facto schema — the only place that enumerates every variable each service accepts. There’s no per-service authoritative list; .env.example is incomplete by convention (only "the common ones"). New-service onboarding reads docker-compose.yml + grep-for-env-var as the de-facto ritual.

  3. No layering semantics. Dev/test/prod differences are expressed by overriding env vars in docker-compose profiles + .env.local. There is no "base + environment override" model — every environment re-specifies everything.

  4. Secrets intermingled with config. Keycloak client secrets, database passwords, and signing keys are the same variable namespace as tuning parameters (TTLs, pool sizes, worker counts). Operators cannot visually separate "change at runtime without redeploy" from "rotate secret via vault" because they live in the same env-var bag.

Precedent: the config crate used today already supports layered sources — add_source(File::with_name(…​)) before add_source(Environment::…​) layers YAML/TOML/JSON files with env overrides in one loader call. CRAIG (a sibling project) uses this pattern successfully with config/default.yaml + config/site.yaml + env override chain.

Options Considered

  1. Keep env-var-only (status quo). Zero migration cost; no typo catching; onboarding friction stays.

  2. YAML-only, no env overrides. Forces Docker/K8s deployments to mount config files or template them at deploy time. Ergonomic regression for container-native deployments where env vars are the standard injection mechanism.

  3. Layered YAML + env overrides (CRAIG pattern). Base config in config/{service}.yaml, environment overlays in config/site.yaml, env vars as the top layer. Struct schema is the source of truth. Env vars still work for Docker/K8s.

  4. TOML instead of YAML. Matches jurisdiction.toml convention. TOML is stricter about structure (no silent null), which is a mild upside. YAML’s multi-line strings + anchor references are mild downsides for config (they’re nice for this use case). Both work with the config crate.

Option 3 is the decision. YAML is chosen over TOML to match the Kubernetes ecosystem (operator-facing files) and the CRAIG precedent; jurisdiction.toml stays TOML because its consumers are policy tooling, not deployment.

Decision

NOTE
Amended by ADR-017 (2026-05-02). Secrets at rest now ship as SOPS-encrypted YAML in secrets/dev.yaml; the runtime contract (env vars) is preserved unchanged. The "Secrets never in checked-in YAML" rule below applies to plaintext YAML in config/ only — encrypted YAML in secrets/ is the new at-rest mechanism for the env-var-injected secrets ADR-012 originally left to deployer practice. The secrets-yaml-lint job enforces the plaintext-secret prohibition over config/*/.yaml; secrets/*.yaml is excluded by path.

Layered YAML config with environment-variable overrides, per-service schema struct, loaded by the config crate.

Layering order (lowest to highest precedence)

  1. config/{service}/default.yaml — checked-in base config, sensible defaults for local dev

  2. config/{service}/site.yaml — environment overlay (dev.yaml / staging.yaml / prod.yaml / test.yaml); optional

  3. CANOPY_{SERVICE}__{SETTING} env vars — highest precedence, unchanged in shape

Later layers override earlier. Env var precedence preserved means Docker/K8s deployments continue to work without config-file mounts.

Per-service schema

Each service defines a single Config struct that mirrors its YAML shape:

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SnapConfig {
    pub server: ServerConfig,
    pub database: DatabaseConfig,
    pub keycloak: KeycloakConfig,
    pub rules: RulesClientConfig,
    pub verification: VerificationAdapterConfig,
    // ... service-specific sections
}

#[serde(deny_unknown_fields)] on every struct catches typos at load time with a clear error identifying the offending key.

Secrets separation

Secrets (DB password, Keycloak client secret, signing keys, S3 credentials) MUST NOT appear in any checked-in YAML. They come from env vars only. YAML files may document the expected env-var name via a placeholder:

database:
  url: "postgres://canopy@postgres/canopy_snap"
  password_env: "CANOPY_SNAP__DB_PASSWORD"    # loader reads this env var

The loader resolves _env suffixed fields at startup. Rationale: operators can grep YAML for _env: to audit which secrets are injected.

Backwards compatibility during migration

Migration is not atomic — Canopy has 19 services. During rollout, the loader chain is: existing env vars continue to work identically (top precedence, same separator), YAML overlay loads if present, missing YAML falls back to env-only behaviour. A service is "migrated" when its YAML file is checked in and its Rust loader switches to the layered source list; it can be rolled back by deleting the YAML and reverting the loader call.

Implementation is tracked separately in GitLab issue #291 and a follow-up implementation plan. This ADR ratifies the direction, not the schedule.

Consequences

Positive

  • Typo catching at load time. deny_unknown_fields + struct schema fails fast on misspelled keys in YAML or env vars.

  • Self-documenting schema. A config/{service}/default.yaml checked into the repo is the authoritative list of every setting the service accepts, replacing docker-compose.yml as the de-facto schema.

  • Environment layering. dev.yaml vs. prod.yaml expresses differences explicitly rather than re-specifying every value per profile.

  • Secrets audit. grep _env: config/ surfaces every injected secret across services.

  • Docker/K8s unchanged. Env var overrides still work, so deployment tooling does not need to change.

  • Smaller docker-compose.yml. Env var blocks shrink to secrets + environment-specific overrides; the tuning-parameter bulk moves to YAML.

Negative

  • Two config sources during transition. Operators and developers must know both conventions until migration completes. Mitigated by per-service rollout — each service is either fully migrated or fully env-only, never mid-state.

  • Precedence confusion. A stale env var can silently override a corrected YAML value. Mitigated by startup log emission: every loaded config value prints its source (file:config/snap/default.yaml vs. env:CANOPY_SNAP__X) at DEBUG level.

  • Per-service migration churn. 19 services × (YAML file + struct definition + loader swap + docker-compose env-removal + test updates) ≈ 19 MRs plus a final cleanup pass.

  • YAML anchor abuse risk. YAML’s &anchor / <<: *ref features are tempting for DRY but can make diffs confusing. Convention: no anchors in checked-in config; duplication preferred for reviewability.

Constraints

  • No policy values in service config. All regulatory thresholds continue to live in rulesets/{jurisdiction}/jurisdiction.toml per ADR-011. Service config covers infrastructure (ports, URLs, TTLs, pool sizes) — never policy.

  • Schema struct is the source of truth. The Config struct defines the valid keys; YAML and env vars must match it. This is the inverse of "env vars define the keys; code reads what it needs."

  • Secrets never in checked-in YAML. Enforced by a CI lint (added with the first migrated service) that greps for common secret key names in config/*/.yaml.

  • Migration is opt-in per service. No global cutover. Each service’s migration MR is independently reviewable and revertible.

Not addressed by this ADR

  • CLI arg layer. CRAIG layers CLI args above env vars. This ADR leaves CLI args out of scope — Canopy services are long-running daemons, not scriptable CLIs, and the canopy CLI already has its own argument-parsing story per ADR-007.

  • Hot reload. Config is loaded at startup only. Adding SIGHUP/inotify-based reload is a separable future ADR if needed.

  • Per-tenant config. Multi-jurisdiction deployments today use separate service instances per jurisdiction (via ADR-005 deployment profiles); per-tenant config within a single instance is not in scope.

Edit this page · default