Environments by Inheritance
Last reviewed · content updated
IntermediateWhat you'll learn
~18 min- Express environments as a strict baseline plus visible, annotated diffs
- Resolve configuration by file placement instead of conditional logic
- Size state granularity to blast radius
The disease and the cure, briefly
You met baseline+diff at overview altitude in Cloud Modernization (its golden-paths lesson); here’s the mechanics, from the estate that runs it. The disease: five environment files that began as copies, drifted independently, and now nobody can answer “how does prod differ from dev?” without a diff nobody trusts. The cure: environments declare only their difference from a strict baseline — and the baseline is the secure posture, with dev relaxing visibly, never prod remembering to tighten.
The mechanics: three moves
Move 1 — the baseline is a file, and controls live in it once:
# _envcommon/compliance_baseline.hcl - the strict posture, one placelocals { public_network_access = "Disabled" # SC-7 min_tls_version = "1.2" # SC-8, SC-13 diagnostic_settings = "all-to-central" # AU-2, AU-12 soft_delete_days = 90 # CP-9 purge_protection = true # CP-9}Move 2 — each environment carries only its annotated deltas:
# envs/dev/env.hcl - the ENTIRE environment definitionlocals { compliance_overrides = { # SC-7 risk-accepted for dev: no customer data present; PE cost # avoided. Accepted by: platform lead, 2026-06-02. Review: quarterly. public_network_access = "Enabled" # CP-9 relaxed: dev data is disposable by policy soft_delete_days = 7 }}The dev file is the teaching centerpiece — read what it is: a complete, reviewable register of exactly how dev’s posture differs from strict, each delta carrying the traded-off control ID, the acceptor, and a review date. “What’s different about dev?” is now cat env.hcl, and a diff to it is a risk-acceptance decision arriving in a PR — visible to review, greppable forever. (Prod’s file, ideally, is nearly empty. An empty prod overrides file is the architecture succeeding.)
Move 3 — resolution by placement, not conditionals. The estate’s tooling (Terragrunt-style: find_in_parent_folders() locates the layered files and include blocks with deep merge — or read_terragrunt_config() plus merge() — combine them) resolves baseline + overrides by walking up the directory tree from wherever a stack sits:
live/ _envcommon/compliance_baseline.hcl <- found by upward lookup envs/ dev/ env.hcl <- dev's deltas networking.hcl <- dev's region/vnet facts storage-account/terragrunt.hcl <- a stack: inherits ALL of the above by WHERE IT SITS prod/ env.hcl <- prod's (near-empty) deltasNo var.environment == "dev" ? ... : ... branching anywhere — a stack’s configuration is a function of its location, so moving a stack between environments is a git mv of its configuration (state follows the path too — migrate the state or treat the move as a new deployment, because a fresh plan at the new path will try to recreate everything), and reading any stack’s effective config is a walk up its path. Conditionals scattered through modules are where environment logic goes to hide; placement is where it goes to be seen.
The granularity decision
The same estate makes a choice worth debating rather than copying blindly: one state file per leaf directory — 287 leaves, 287 states. The trade: maximal blast-radius isolation (a corrupted state or a bad apply touches one resource group’s worth of world; plans are fast; locks never contend) versus orchestration cost (cross-stack references need explicit dependency wiring; “plan the world” is a graph run, not one command). The principle to carry, not the number: size state to the blast radius you can tolerate, and let your orchestrator (this is exactly what Terragrunt-class tooling is for) pay the coordination bill. A monolithic state whose every plan can touch everything is the quiet default nobody chose; 287 might be more than you need; the deliberate answer is per-application-per-environment at minimum.
Build the inheritance tree for Meridian: (1) compliance_baseline.hcl withour eight non-negotiables, control-annotated; (2) env.hcl for dev, test,prod - dev carrying two risk-accepted deltas in the full format (control,reason, acceptor, review date), prod carrying NONE; (3) the directorylayout with per-app-per-env stacks inheriting by placement; (4) wire onewrapper from Lesson 3.1 as a stack in dev and show its EFFECTIVE config(baseline ⊕ dev deltas) in the plan; (5) the CI check: fail any PR whoseoverride lacks a control ID + reason + review date - the format is onlyreal if the pipeline enforces it.If you’re placing this in the wider ecosystem: upward-lookup inheritance, DRY environment trees, and multi-stack orchestration are exactly what Terragrunt (now past 1.0, with its own Stacks feature on the free CLI side) exists to do — the estate’s pattern is a disciplined application of it, and it fronts OpenTofu as happily as Terraform. Knowing the pattern’s name makes it portable: you can implement the same shape with Terragrunt, with native tooling, or (partially) with workspaces-plus-convention, and evaluate vendor “environments” features by whether they preserve the two properties that matter — strict base, visible diffs.
During review, a PR adds to prod's env.hcl: 'min_tls_version = '1.2' # keeping prod explicit for clarity — same value as baseline, just visible.' Harmless documentation, or reject?
Key takeaway
One strict baseline holding the controls; environments as pure delta registers where every entry names its control, reason, acceptor, and review date; resolution by file placement instead of scattered conditionals; state sized to tolerable blast radius. Prod’s near-empty overrides file is the goal state — and the CI check on annotation format is what keeps the register honest. Next: what all these pins and modules are actually protecting you from.