The root contract¶
live/root.hcl is the file every unit includes. It holds the remote state backend,
the shared locals, the generated context, and the default providers, so that a
unit's own terragrunt.hcl stays down to a few lines.
Every unit opens with the same include:
expose = true republishes the root's locals inside the unit as
include.root.locals.<name>, which is how a unit reads the subscription and tenant
it is pointed at.
The locals block¶
root.hcl reads the three scope files and flattens them into six values.
locals {
tenant_vars = read_terragrunt_config(find_in_parent_folders("tenant.hcl"))
subscription_vars = read_terragrunt_config(find_in_parent_folders("subscription.hcl"))
region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
tenant_id = local.tenant_vars.locals.tenant_id
tenant_root_id = local.tenant_vars.locals.tenant_root_id
subscription_id = local.subscription_vars.locals.subscription_id
location = local.region_vars.locals.location
location_short = local.region_vars.locals.location_short
environment = local.region_vars.locals.environment
}
The lookup runs from the including unit's directory, not from live/. That is the
whole inheritance mechanism: the nearest subscription.hcl and region.hcl above a
unit are the ones that apply to it.
The remote state backend¶
remote_state {
backend = "azurerm"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
resource_group_name = get_env("BACKEND_AZURE_RESOURCE_GROUP_NAME", "placeholder-rg")
storage_account_name = get_env("BACKEND_AZURE_STORAGE_ACCOUNT_NAME", "placeholdersa")
container_name = get_env("BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME", "tfstate")
key = "${path_relative_to_include()}/terraform.tfstate"
subscription_id = get_env("AZURE_SUBSCRIPTION_ID", local.subscription_id)
tenant_id = local.tenant_id
use_azuread_auth = true
}
}
Four things here are worth understanding.
The backend file is generated. Terragrunt writes backend.tf into the unit at run
time and overwrites it every run. It is in .gitignore and must never be committed.
The state key is the unit's path. Each unit gets its own state file, and the folder path is the key.
The backend subscription is read separately from the deploy subscription. The
state account lives in the management subscription, which is AZURE_SUBSCRIPTION_ID.
A unit's deploy subscription can be different. Connectivity, for example, deploys
into its own subscription while sharing the one state account.
Access is by Entra ID. use_azuread_auth = true, no storage account keys. The
bootstrap disables shared key access on the account.
Placeholders and offline validation¶
Every get_env has a placeholder fallback. The coordinates come from the AZURE_*
and BACKEND_* Action variables the bootstrap set, which the reusable workflows
export into the run. The fallbacks exist so the tree still generates and validates
fully offline, with init -backend=false and no Azure access at all.
That is why a freshly generated repository validates before anything is configured. It is also why you do not edit backend names in this file. They arrive from the environment.
The default providers¶
root.hcl generates a providers.tf carrying azurerm ~> 4.0 and azapi ~> 2.4,
both pinned to the unit's own subscription and tenant, with
resource_provider_registrations = "none".
That is the set most units need. A unit needing a different set declares its own
generate "provider" block and sets merge_strategy = "deep" on its include:
The deep merge is required, not optional
Without it, two generate blocks with the same name are a hard error, not an
override. The landing-zones and amba units both rely on this because they
need the alz provider.
root.hcl closes with the version floors: terraform_version_constraint = ">= 1.12"
and terragrunt_version_constraint = ">= 1.0".
The generated context¶
The locals above are Terragrunt values. Terraform cannot read them. A third
generate block bridges that gap by writing a context.tf into every unit, the
same way backend.tf and providers.tf are written:
generate "context" {
path = "context.tf"
if_exists = "overwrite_terragrunt"
contents = <<-EOF
locals {
context = {
tenant_id = "${local.tenant_id}"
tenant_root_id = "${local.tenant_root_id}"
subscription_id = "${local.subscription_id}"
location = "${local.location}"
location_short = "${local.location_short}"
environment = "${local.environment}"
}
}
EOF
}
A main.tf then reads the inherited scope as plain Terraform:
locals {
management_resource_group_name = "rg-management-${local.context.location_short}"
}
module "management" {
location = local.context.location
}
Three things follow from this.
region.hcl is the single source of truth for the region. Changing the region
is a one file edit. Every unit under that region scope picks up the new
location and location_short on the next run. It used to take four edits,
because the units hardcoded their own copies.
The values are namespaced under one context object on purpose. A unit is
free to declare its own location local, and local.context.location never
collides with it. A bare location local generated into the unit would.
context.tf is generated at run time and gitignored. It is build output, like
backend.tf and providers.tf. Never commit it.
Cross-scope values are not in the context
The context carries only what the unit inherits through its own ancestors.
connectivity_subscription_id is not in it, because the connectivity
subscription is not an ancestor of _foundation/landing-zones or of the corp
spoke network unit. Both keep it as a literal in their main.tf.
tenant.hcl¶
live/tenant.hcl carries two values and sits at the live/ root deliberately.
find_in_parent_folders only walks ancestors, so this file has to be an ancestor of
every unit.
locals {
tenant_id = get_env("AZURE_TENANT_ID", "00000000-0000-0000-0000-000000000000")
tenant_root_id = local.tenant_id
}
tenant_id is the Entra ID tenant the deployment authenticates against. In CI it
comes from the AZURE_TENANT_ID variable, so the placeholder is used only for local
runs.
tenant_root_id is the management group the ALZ hierarchy is built under. It defaults
to the tenant root group, whose ID equals the tenant ID. Set it explicitly if the
customer already has an intermediate management group to deploy beneath.
region.hcl and subscription.hcl¶
The foundation's copies sit at live/_foundation/.
region.hcl sets the primary region, and it is the single source of truth for it:
All three values reach Terraform through the generated context, so the units read
them rather than restating them. To move a subtree to another region, edit the
region.hcl at that scope and nothing else.
subscription.hcl sets the deploy subscription, again with an environment lookup and
a placeholder fallback:
The foundation is operated from the management subscription, because that is where the deploy identity holds tenant-root rights and where the state backend lives. The management groups and policy it deploys are tenant-scoped, not resources in that subscription, but every unit still authenticates against it.
What you edit here¶
Almost nothing. root.hcl is driven entirely by variables the bootstrap set. Touch it
only to change the Terraform binary, raise a version floor, or add a provider to the
default set. tenant.hcl needs an edit only when the customer deploys under an
existing intermediate management group.
Next: The foundation units.