Skip to content

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:

include "root" {
  path   = find_in_parent_folders("root.hcl")
  expose = true
}

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:

include "root" {
  path           = find_in_parent_folders("root.hcl")
  expose         = true
  merge_strategy = "deep"
}

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:

location       = "westeurope"
location_short = "weu"
environment    = "prod"

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:

subscription_id = get_env("AZURE_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")

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.