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— 208CANOPY_*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:
-
No schema validation. A typo like
CANOPY_SNAP__GROSS_INCOME_CEILLINGsilently deserialises toNone/ 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. -
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.exampleis incomplete by convention (only "the common ones"). New-service onboarding reads docker-compose.yml + grep-for-env-var as the de-facto ritual. -
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. -
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
-
Keep env-var-only (status quo). Zero migration cost; no typo catching; onboarding friction stays.
-
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.
-
Layered YAML + env overrides (CRAIG pattern). Base config in
config/{service}.yaml, environment overlays inconfig/site.yaml, env vars as the top layer. Struct schema is the source of truth. Env vars still work for Docker/K8s. -
TOML instead of YAML. Matches
jurisdiction.tomlconvention. 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 theconfigcrate.
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
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)
-
config/{service}/default.yaml— checked-in base config, sensible defaults for local dev -
config/{service}/site.yaml— environment overlay (dev.yaml/staging.yaml/prod.yaml/test.yaml); optional -
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.yamlchecked 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.yamlvs.prod.yamlexpresses 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.yamlvs.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/<<: *reffeatures 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.tomlper ADR-011. Service config covers infrastructure (ports, URLs, TTLs, pool sizes) — never policy. -
Schema struct is the source of truth. The
Configstruct 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
canopyCLI 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.