Skip to content

The foundation units

live/_foundation/ holds the three units that build the platform: the management resources, the management group hierarchy with its policy, and Azure Monitor Baseline Alerts. This page covers what each one deploys, the module version it pins, and the values you must edit before the first apply.

The shape of a unit

Every foundation unit is a pair of files.

File Owns
terragrunt.hcl How the unit wires into the tree: the root include, the backend, the providers, the ordering.
main.tf Plain Terraform. A module block sourcing an Azure Verified Module, with the inputs inline.

The AVM module owns the schema, so main.tf is ordinary Terraform you can read without knowing Terragrunt. Terragrunt generates the backend, the providers, and the context, from the root contract.

Each unit has its own state, keyed by its path under the live tree. A change to the AMBA unit does not re-plan the policy unit. They apply in one order:

management-resources     # no dependencies
  -> landing-zones       # dependencies { paths = ["../management-resources"] }
    -> amba              # dependencies { paths = ["../landing-zones"] }

The dependencies blocks are ordering only. They declare no outputs and read no values from each other. Where one unit needs a value from another, the value is rebuilt from a naming convention instead. That constraint is explained under landing-zones below.

Where location comes from

The units take no Terragrunt inputs. They read the region from the generated context.tf instead, which live/root.hcl writes into every unit at run time from the nearest region.hcl. All three main.tf files use local.context.location and local.context.location_short, and management-resources/main.tf also tags with local.context.environment.

So live/_foundation/region.hcl is the single source of truth for the foundation's region. Changing it is a one file edit, and the three units follow on the next run. There is nothing to keep in sync by hand. See The root contract.

Values that are not inherited stay literal. landing-zones/main.tf keeps connectivity_subscription_id as a literal because the connectivity subscription is not an ancestor of the foundation folder, so no subscription.hcl above it carries that value.

management-resources

The central monitoring resources every other unit points at.

Item Value
Module Azure/avm-ptn-alz-management/azurerm
Version 0.9.0
State its own

It deploys a resource group, a Log Analytics workspace with 30 day retention, three data collection rules (change tracking, VM insights, Defender for SQL), and one user-assigned managed identity for the Azure Monitoring Agent. The automation account is switched off with automation_account_name = null.

Its terragrunt.hcl is the minimal case: a root include with expose = true, and nothing else. No generate "provider" block, because the root default of azurerm plus azapi is exactly what the module needs. No dependencies, because it is first.

The six resource names it creates are a contract:

rg-management-weu
law-management-weu
dcr-change-tracking-weu
dcr-vm-insights-weu
dcr-defender-sql-weu
uami-management-ama-weu

The landing-zones unit rebuilds every one of those names by string, to point policy at them. They must not drift. If you rename one here, rename it there in the same commit.

You edit: the businessunit tag in locals.tags, which ships as changeme.

landing-zones

The management group hierarchy, the base policy set, and subscription placement.

Item Value
Module Azure/avm-ptn-alz/azurerm
Version 0.21.0
State its own

What the terragrunt.hcl does differently

It sets merge_strategy = "deep" on the root include and declares its own generate "provider" block. Two generate blocks with the same name are a hard error under the default merge, so the deep merge is what lets the child block override the root's instead of colliding with it.

The block it generates requires alz (~> 0.21) and azapi (~> 2.4). There is no azurerm provider in this unit at all: the module talks to Azure through azapi and reads its policy assets through alz.

The alz provider is configured with an ordered list:

provider "alz" {
  library_overwrite_enabled = true
  library_references = [
    { path = "platform/alz", ref = "2026.04.2" },
    { custom_url = "${get_terragrunt_dir()}/lib" },
  ]
}

Order is the whole point. The stock ALZ library is read first, then the vendored local library layers on top, and library_overwrite_enabled = true lets a local asset replace a stock asset of the same name. Reverse the two and the stock library wins. See The vendored policy library.

Policy default values, computed from names

policy_default_values feeds the shared monitoring ids to every assignment parameter in the library that declares them. The unit builds each id as a string, concatenating the subscription id, the management resource group name, the provider namespace, and the resource name.

It does not read them from the management-resources state. It cannot. The alz provider reads a data source at plan time and a data source cannot take a value that is unknown until apply. So the ids are computed from the naming convention, which is why those six names are a contract rather than a detail.

The active policy_assignments_to_modify

Three entries ship active:

Management group Assignment Change
alz Enforce-Tag-Gov All four effect parameters pinned to Audit.
connectivity Enable-DDoS-VNET creation_enabled = false.
landingzones Enable-DDoS-VNET creation_enabled = false.

Tag governance stays advisory while resource groups are reconciled. The DDoS assignment is opted out so VNet creates work without a DDoS plan, which carries a monthly cost. The reasoning behind both, and the order to harden them in, is Enforcement strategy.

The two commented blocks

main.tf carries two large commented locals, both inert. They ship commented on purpose: they are reference material to lift from, not configuration.

  • common_customizations: the stock ALZ settings customers change on day one, led by the Microsoft Defender for Cloud config assignment. It also documents each modify capability as a worked snippet: enforcement_mode, creation_enabled, parameters, phased resource_selectors, not_scopes, overrides, and non_compliance_messages.
  • prod_hardening: the recommended move from the soft AVM default posture to enforced. Tag governance to Deny, then a Wave 1 of data service guardrails, then a Wave 2 of compute, container, network, and app guardrails, then private subnets. Customer-managed keys are left commented out even inside the block.

To use either, copy the entries you want into policy_assignments_to_modify, merging by management group key. Do not enable a whole block at once. Read Enforcement strategy first.

You edit: nothing, in most repositories. architecture_name already ships as "nrit", matching the single architecture the vendored lib/ defines, and connectivity_subscription_id ships as an empty string, which is valid: the unit drops the connectivity entry from subscription_placement when it is empty. Set connectivity_subscription_id in landing-zones/main.tf when you onboard a connectivity hub, and change architecture_name only if you add a second architecture to lib/.

amba

Azure Monitor Baseline Alerts. Two modules in one state.

Item Value
Modules Azure/avm-ptn-monitoring-amba-alz/azurerm and Azure/avm-ptn-alz/azurerm
Versions 0.4.0 and 0.21.0
State its own, shared by both modules

amba_resources creates the AMBA resource group and the user-assigned identity that remediation runs as. amba_policy is the same ALZ pattern module as the landing-zones unit, but with architecture_name = "amba", overlaying the AMBA policy set on the hierarchy that already exists.

They share one state because the policy needs the identity the resources create. This is a genuinely computed cross-reference, not a name that can be rebuilt: the module takes policy_assignments_dependencies and policy_role_assignments_dependencies, both set to [module.amba_resources]. A module reference like that only works inside one configuration, so the two live together.

Its terragrunt.hcl also deep-merges the root include and generates its own providers, but the set is alz, azapi, and azurerm, since the AMBA resources module needs azurerm. Its alz provider takes two stock library refs and no local library:

library_references = [
  { path = "platform/alz", ref = "2026.04.2" },
  { path = "platform/amba", ref = "2026.06.2" },
]

You edit: amba_action_group_email in amba/main.tf, which ships as alerts@example.com.

Two things that will bite you in the AMBA unit

The action group email is wrapped in a list: [local.amba_action_group_email]. The policy parameter behind it is of type Array. A bare string passes plan and fails at apply with InvalidPolicyParameterType.

The amba_resources module is called with no tags. AMBA policy remediation redeploys that resource group and stamps its own _deployed_by_amba tag, stripping whatever Terraform set. Manage tags there and the unit drifts every remediation cycle. Leave the resource group AMBA-owned.

Everything pinned in the foundation

Module or library Version Unit
Azure/avm-ptn-alz-management/azurerm 0.9.0 management-resources
Azure/avm-ptn-alz/azurerm 0.21.0 landing-zones
Azure/avm-ptn-monitoring-amba-alz/azurerm 0.4.0 amba
Azure/avm-ptn-alz/azurerm 0.21.0 amba
platform/alz (ALZ library) 2026.04.2 landing-zones, amba
platform/amba (ALZ library) 2026.06.2 amba
Provider Constraint Where
hashicorp/azurerm ~> 4.0 root default, so management-resources; also amba
Azure/azapi ~> 2.4 root default and all three units
Azure/alz ~> 0.21 landing-zones, amba

Terraform is constrained to >= 1.12 and Terragrunt to >= 1.0, both set in live/root.hcl.

How and when to move any of these pins is covered in Versions and upgrades.

Next: The vendored policy library.