Dependencies Between Units
The idea in one sentence
Section titled “The idea in one sentence”A dependency block lets one unit read another unit’s outputs across the state boundary that separates them, with mock_outputs standing in for the real values until that other unit has actually been applied.
Why module composition is not enough anymore
Section titled “Why module composition is not enough anymore”The Modules module showed one module’s output feeding straight into another module’s input, all inside a single main.tf, resolved by Terraform in one plan against one shared state. That worked because both modules were part of the same Terraform run — Terraform could see the whole dependency graph at once.
Units break that assumption on purpose. A vnet unit and a vm unit each have their own terragrunt.hcl and their own separate state file, applied independently. The vm unit still needs the subnet ID that vnet creates, but there is no longer a single Terraform run that sees both — the two units are, from Terraform’s point of view, completely unrelated projects. Terragrunt needs its own mechanism to hand a value from one unit’s state across that boundary into another unit’s inputs, and that mechanism is the dependency block.
The dependency block: crossing the state boundary
Section titled “The dependency block: crossing the state boundary”A dependency block names another unit by its directory and exposes everything that unit outputs:
include "root" { path = find_in_parent_folders("root.hcl")}
dependency "vnet" { config_path = "../vnet"}
inputs = { subnet_id = dependency.vnet.outputs.subnet_id}config_path = "../vnet" points at the vnet unit’s directory. Behind the scenes, Terragrunt runs terragrunt output against that unit and exposes the result under dependency.vnet.outputs, so dependency.vnet.outputs.subnet_id reaches straight into whatever the vnet unit’s Terraform module declared as an output "subnet_id". From the vm unit’s own terragrunt.hcl, this reads almost exactly like consuming a module output inside a single main.tf — the difference is invisible in the syntax, even though a completely separate apply and a completely separate state file sit behind dependency.vnet.outputs.
mock_outputs: letting plan succeed early
Section titled “mock_outputs: letting plan succeed early”There is an obvious problem with the setup above: the very first time anyone runs terragrunt plan on the vm unit, the vnet unit might not have been applied yet, so dependency.vnet.outputs.subnet_id has no real value to read. mock_outputs solves exactly that:
dependency "vnet" { config_path = "../vnet"
mock_outputs = { subnet_id = "mock-subnet-id" }
mock_outputs_allowed_terraform_commands = ["plan"]}When the real output is not yet available, Terragrunt substitutes the values in mock_outputs instead of failing outright, letting plan show what the vm unit would create even before vnet exists. mock_outputs_allowed_terraform_commands restricts exactly which commands are allowed to fall back to the mock — listing only plan here means an apply still fails loudly if the real dependency has not been applied yet, which is exactly the safety net you want: it is fine to preview a plan against a placeholder subnet ID, but never fine to actually create a virtual machine against one.
Apply order is computed automatically
Section titled “Apply order is computed automatically”Nothing in the vm unit’s terragrunt.hcl says “apply vnet first.” Terragrunt works that out on its own: every dependency block it finds while scanning a tree of units becomes an edge in a dependency graph, and when you run an operation across multiple units at once, Terragrunt applies that graph as an ordering constraint — vnet is guaranteed to finish applying before vm starts, because vm declares a dependency on it. You never hard-code that ordering or remember to run commands in the right sequence by hand; the next lesson covers the command that actually triggers a multi-unit run.
flowchart LR vnetunit["vnet unit (own state)"] vnetout["outputs.subnet_id"] dep["vm/terragrunt.hcl dependency vnet"] vminputs["vm inputs.subnet_id"] vnetunit --> vnetout --> dep --> vminputs mock["mock_outputs (plan only, before vnet is applied)"] -.-> dep
Proof: a real two-unit tree, run --all plan before vnet is ever applied
Section titled “Proof: a real two-unit tree, run --all plan before vnet is ever applied”A real Terragrunt 1.1.0 tree with a local backend (no Azure needed to prove the mechanism — vnet outputs a subnet_id string the same way azurerm_subnet.this.id would):
remote_state { backend = "local" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }}include "root" { path = find_in_parent_folders("root.hcl")}
terraform { source = "."}terraform { required_providers { local = { source = "hashicorp/local", version = "~> 2.5" } }}
resource "local_file" "vnet" { filename = "${path.module}/subnet-id.txt" content = "subnet-0123456789abcdef0\n"}
output "subnet_id" { value = "subnet-0123456789abcdef0"}vm/terragrunt.hcl is exactly the dependency block shown above (config_path = "../vnet", mock_outputs_allowed_terraform_commands = ["plan"]); its module:
terraform { required_providers { local = { source = "hashicorp/local", version = "~> 2.5" } }}
variable "subnet_id" { type = string }
resource "local_file" "vm" { filename = "${path.module}/vm-subnet-ref.txt" content = "launched into ${var.subnet_id}\n"}Before vnet has ever been applied, running terragrunt run --all -- plan from the tree root:
$ terragrunt run --all --non-interactive -- planINFO The following units will be run, starting with dependencies and then their dependents:╰── vnet ╰── vmWARN [vm] Config .../vnet/terragrunt.hcl is a dependency of .../vm/terragrunt.hcl that has no outputs, but mock outputs provided and returning those in dependency output.STDOUT [vm] terraform: launched into mock-subnet-idThe ordering log lists vnet before vm purely from the dependency "vnet" block — nothing in either unit says “run me second” — and the plan content, launched into mock-subnet-id, is the literal mock_outputs value, proving mock_outputs_allowed_terraform_commands = ["plan"] is what let this succeed with zero real vnet state on disk.
Now apply vnet for real (still fully offline — local_file, no cloud) and re-run vm’s plan with no config changes at all:
$ terragrunt run --non-interactive -- apply -auto-approve # inside vnet/Outputs:subnet_id = "subnet-0123456789abcdef0"
$ terragrunt run --non-interactive -- plan # inside vm/, unchanged configSTDOUT terraform: launched into subnet-0123456789abcdef0Same vm/terragrunt.hcl, same dependency block — the plan content flips from the mock value to the real terraform output value the instant vnet has real state, with zero edits on the vm side.
The real error when a dependency has no outputs and the command isn’t allow-listed
Section titled “The real error when a dependency has no outputs and the command isn’t allow-listed”apply was deliberately left out of mock_outputs_allowed_terraform_commands = ["plan"] above. Running terragrunt apply against vm before vnet has been applied — the real Terragrunt 1.1.0 error, not paraphrased:
$ terragrunt apply --non-interactive # vm/, before vnet has been applied, apply not in the allow-listERROR resolving dependency "vnet" outputs: .../vnet/terragrunt.hcl is a dependency of .../vm/terragrunt.hcl but detected no outputs. Either the target module has not been applied yet, or the module has no outputs.
If this dependency is accessed before the outputs are ready (which can happen during the planning phase of an unapplied stack), consider using mock_outputs: ...This is the guardrail mock_outputs_allowed_terraform_commands exists to enforce — proven here as Terragrunt’s real error text, not just asserted: a plan may proceed on a placeholder, but apply is refused outright rather than silently creating a real Azure virtual machine wired to a fake subnet ID.
🐛 Spot the bug
dependency "vnet" {config_path = "../vnet"
mock_outputs = { subnet_id = "mock-subnet-id"}
mock_outputs_allowed_terraform_commands = ["plan", "apply"]}
inputs = {subnet_id = dependency.vnet.outputs.subnet_id}Show the answer
The bug is "apply" sitting in mock_outputs_allowed_terraform_commands alongside "plan". Proven above, restricting the list to ["plan"] is what makes apply refuse to run with a real error until vnet has genuinely been applied — with "apply" included, terragrunt apply on vm succeeds silently using subnet_id = "mock-subnet-id", creating a real Azure virtual machine wired to a placeholder value instead of the real subnet. The fix is exactly the config shown above: leave apply out of the allow-list.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated dependency blocks, check for:
mock_outputs_allowed_terraform_commandsincluding"apply"— proven above, that specific safety refusal exists precisely to stopapplyfrom running against fake data.mock_outputspresent with nomock_outputs_allowed_terraform_commandsat all — the mock is never used (the allow-list defaults to empty, not “always”), so a fresh environment’s firstplanfails with the exact “detected no outputs” error proven above.- A
dependencies { paths = [...] }block where the code then tries to readdependencies.vnet.something— that attribute never exists on adependenciesblock; onlydependency(singular) exposes.outputs.
Mental Model
Section titled “Mental Model”A dependency block is Terragrunt’s stand-in for the cross-module reference Terraform gives you for free inside one state — proven above, it works by shelling out to terraform output -json against the other unit and substituting the result in before this unit’s own plan/apply ever starts. mock_outputs exists because that call can come back empty, and Terragrunt needs an explicit, command-scoped answer for “what do I do then.”
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
dependency blocks instead of one shared Terraform state | Independent state per unit; run --all still gets correct ordering, proven above | An extra terraform output shell-out per dependency per run |
mock_outputs scoped by mock_outputs_allowed_terraform_commands | plan/CI on a partially-applied environment doesn’t hard-fail, proven above | Proven above: getting the allow-list wrong (including apply) silently trades a loud, correct failure for a quiet, wrong one — here, a real VM wired to a fake subnet |
Common Misconceptions
Section titled “Common Misconceptions”- “A dependency block links two units’ state together.” Proven above: Terraform never sees
vnetat all when planningvm. Terragrunt resolvesdependency.vnet.outputs.subnet_idto a plain string first, via a separateterraform outputcall. - “mock_outputs are used automatically whenever a dependency’s real state is missing.” Proven above with the real apply error: mocks are only used for commands explicitly listed in
mock_outputs_allowed_terraform_commands.