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, phasedresource_selectors,not_scopes,overrides, andnon_compliance_messages.prod_hardening: the recommended move from the soft AVM default posture to enforced. Tag governance toDeny, 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.