Modern DevSecOps Foundations Module 3 · IaC Foundations

Environments by Inheritance

Last reviewed · content updated

Intermediate

What 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 place
locals {
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 definition
locals {
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) deltas

No 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 with
our 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 directory
layout with per-app-per-env stacks inheriting by placement; (4) wire one
wrapper 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 whose
override lacks a control ID + reason + review date - the format is only
real if the pipeline enforces it.
ℹKinship: this is the Terragrunt pattern, industrialized

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.

KNOWLEDGE CHECK

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.

Search lessons