Sharing data between units¶
Each unit is its own Terraform root with its own state. Separate state is what gives a small blast radius, but it also means one unit cannot reference another unit's resources directly. This page is how to order units and move values between them, choosing the lightest option each time.
Two Terragrunt blocks, do not confuse them¶
dependencies(plural): ordering only. It lists paths that must apply first. It reads no values. Use it when unit B must run after unit A but does not need anything A computed.dependency(singular): data. It reads another unit's remote-state outputs and exposes them asdependency.<name>.outputs.<x>. Use it only when you actually need a value A produced.
The foundation uses dependencies for the apply order (management-resources,
then landing-zones, then amba) and needs no dependency block, because the
only things that cross are predictable names, not computed values.
Choosing how to share a value: take the first that fits¶
Work down this list. The earlier options keep a unit as just main.tf plus
terragrunt.hcl and add no coupling.
1. Convention: derive the same name in both units¶
Best for names you control and can predict. The producer creates the resource with a derived name; the consumer rebuilds the same name and, if it needs the id, constructs it. Nothing crosses state.
# management-resources unit creates it
resource_group_name = "rg-management-${local.context.location_short}"
# landing-zones unit references it by convention (must stay in sync)
management_providers_scope = "/subscriptions/${local.management_subscription_id}/resourceGroups/rg-management-${local.context.location_short}/providers"
local.context is generated into every unit by live/root.hcl, so the region
is read from region.hcl on both sides and cannot drift between them.
Cost: the two units share a naming rule that must not drift. Comment the coupling on both sides.
2. Data source: look up a live value by its known name¶
Best for a value Azure computes that you cannot predict but can query, when you
already know the resource's name. This stays inside the consuming unit's
main.tf, adds no dependency, and does not couple to the other unit's state.
# get the AMA identity's principal id without touching the producer's state
data "azurerm_user_assigned_identity" "ama" {
name = "uami-management-ama-${local.context.location_short}"
resource_group_name = "rg-management-${local.context.location_short}"
}
# data.azurerm_user_assigned_identity.ama.principal_id
Cost: the consumer applies after the producer (add a dependencies path so the
resource exists when the data source reads it).
3. Key Vault: for secrets¶
Best for an APIM key, a connection string, or any secret. The producing unit writes the secret to Key Vault; the consuming unit reads it with a data source.
data "azurerm_key_vault_secret" "apim_key" {
name = "apim-primary-key"
key_vault_id = data.azurerm_key_vault.platform.id
}
Do not pass a secret through a dependency output. That writes the secret into
both state files. Key Vault keeps it in one place and out of Terraform state.
4. Terragrunt dependency: computed value, last resort¶
Use only when the value is genuinely computed and cannot be looked up (for example a generated id with no stable name). It reads the producer's remote-state outputs.
# consumer terragrunt.hcl
dependency "apim" {
config_path = "../apim"
# so plan works before the producer is applied. Mocks must never reach apply.
mock_outputs = { gateway_url = "https://mock" }
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
apim_gateway_url = dependency.apim.outputs.gateway_url
}
Cost, and where the two-file minimalism ends:
- The producer must expose the value as an
output(it needs anoutputs.tf). - The consumer must declare a
variableto receive the input (it needs avariables.tf), and the value is written into the consumer's state. - The producer must be applied before the consumer, or the mock is used for
plan. Always set
mock_outputs_allowed_terraform_commands = ["validate", "plan"]so a mock can never leak into an apply.
The design principle¶
Put unit boundaries where only static or convention data crosses. Keep two
things that hand each other a computed value in the same unit. That is why
the AMBA resources and the AMBA policy share one unit: the policy needs the
identity the resources create, so a plain module.amba_resources reference in
one state is simpler and safer than a cross-state dependency.
When you find yourself reaching for a dependency between two units, treat it
as a signal: either a data-source lookup or Key Vault fits, or those two units
are coupled enough that they belong together.
Decision table¶
| You need | Use | Extra files |
|---|---|---|
| B to apply after A, no value | dependencies (paths) |
none |
| A name you control | convention (same derived name) | none |
| A live value with a known name | data source |
none |
| A secret | Key Vault + data source |
none |
| A computed value with no stable name | dependency block |
producer output, consumer variable |
Anti-patterns¶
- Secrets through
dependencyoutputs. They land in both states. Use Key Vault. - A
dependencywhere adatasource would do. It couples state and forces an apply order you may not need. - Deep
dependencychains. They makerun --allslow and fragile. Prefer flat graphs and lookups. - Reading outputs when you only need ordering. Use
dependencies, notdependency. - A
dependencywith mocks but nomock_outputs_allowed_terraform_commands. The mock can then reach apply and deploy wrong values.
Why this matters¶
- WAF Operational Excellence: lookups and Key Vault keep units small and independent, so most changes touch one unit and one plan.
- WAF Reliability: fewer cross-state links means a smaller blast radius and
a shallower
run --allgraph. - WAF Security: secrets stay in Key Vault, not in Terraform state or plan output.
Next: Runbook.