CoursesTerragruntDependencies & outputs

Dependencies & outputs

Wire modules together safely.

Advanced14 min · lesson 7 of 12

The network crew finishes the VPC (virtual private cloud, a private network carved out inside a cloud account) and hangs the results on a labelled board by the door: network ID here, private subnet IDs there, database firewall group over there. The application crew walks over, reads the two labels it needs, and gets on with its own work. Nobody photocopies the network crew's paperwork. Terragrunt's dependency block is that board.

A unit, in Terragrunt terms, is one directory holding a terragrunt.hcl file. In plain Terraform you wire two units together with a terraform_remote_state data source, which makes you hand-copy the producer's backend settings (bucket name, key path, region, encryption key) into the consumer. Every copy is a fact that can go stale quietly. A stale key path means you read someone else's state, or nothing at all. Terragrunt replaces the whole arrangement with a folder path.

The Dependency Block

A dependency block gives a local name to another unit and a config_path pointing at the directory that holds its terragrunt.hcl. Everything that unit publishes then shows up as dependency.<name>.outputs.<key>, usable anywhere in your config and most often inside inputs. Declare as many blocks as you need. Two facts matter. config_path is a directory, resolved relative to the file the block sits in, not relative to your shell. And the values you get back are real applied outputs, read from the producer's state, not a fresh plan. Whatever you write into inputs is handed to the engine (OpenTofu or Terraform, whichever binary Terragrunt is driving) as TF_VAR_ environment variables.

live/prod/us-east-1/app/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::[email protected]:acme/tf-modules.git//ecs-service?ref=v2.1.0"
}
# Read the network unit's published outputs
dependency "vpc" {
config_path = "../vpc"
}
# Read the firewall unit's published outputs
dependency "sg" {
config_path = "../security-groups"
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.private_subnet_ids
sg_ids = [dependency.sg.outputs.app_sg_id]
}

Declaring a dependency does two jobs at once. It fetches values, and it draws an edge in the graph Terragrunt uses to order a whole-stack run. The VPC applies before the app, always, without you saying so anywhere else. That second job is why a wrong config_path is worse than a typo. It breaks a value, and it also deletes an ordering guarantee you were quietly relying on.

What Terragrunt Really Does To Read An Output

The read costs real time. Here is where the time goes. Terragrunt does not open the state file and pick a value out of it. In the usual case it cuts a key blank that fits exactly one lock and nothing else: it parses the target unit's config, pulls out only the remote_state block, makes a throwaway directory under the download dir, writes a single backend.tf into it, copies the provider lock file across, runs init, runs output -json, then deletes the directory. The VPC module's own source code is never downloaded. That shortcut is called dependency output optimization, and you can switch it off with disable_dependency_optimization = true on the remote_state block.

Two other paths exist. If the target unit's cache directory has already been init-ed during this run, Terragrunt skips the scratch workspace and runs output -json right there. If it cannot pull a usable remote_state block out of the target, because you configured the backend inside the module instead, say, it falls back to a full nested Terragrunt run in that directory, which does download the module source and its providers. That fallback is the slow one, and it is why planning a ten-unit stack can sit there for a minute showing you nothing. You can watch the whole thing by hand.

terminal
cd live/prod/us-east-1/vpc
# Terragrunt's own log lines go to stderr, so the pipe into jq stays clean
terragrunt output -json | jq 'keys'
output
[
"database_sg_id",
"private_subnet_ids",
"vpc_id"
]

Now the part that decides your permissions model. Whoever runs the app unit needs read access to the VPC unit's state. On Amazon S3 (Simple Storage Service, an object store) that means s3:ListBucket on the bucket, s3:GetObject on the state key, and kms:Decrypt on the KMS (key management service) key if the bucket is encrypted with one. No PutObject, no DeleteObject, no lock. If your CI (continuous integration, the robot that runs your pipeline) role can plan the app but cannot read the network state, the run dies while Terragrunt is still resolving the app's config, long before the app's own plan starts, and the error it prints names an S3 bucket rather than your HCL (HashiCorp Configuration Language) file. People lose hours to that.

On S3 you can skip the scratch workspace entirely with --dependency-fetch-output-from-state (environment variable TG_DEPENDENCY_FETCH_OUTPUT_FROM_STATE). The flag switches on an experiment of the same name, and Terragrunt then pulls the state object straight out of the bucket with Amazon's own client library instead of shelling out to the engine. On a large stack it is noticeably faster. Three things come with it. It works on the S3 backend only, and falls back to the normal path on anything else. It fails outright if you use OpenTofu state encryption, because the object it downloads is ciphertext it holds no key for. And the caller is now reading the whole state file, which contains every attribute of every resource in that unit, not the curated handful the module chose to publish as outputs. Grant that on purpose, to one named role, and not by habit.

How one value crosses a dependency edge
1vpc unit applied
its outputs land in that unit's remote state
2app unit parsed
dependency "vpc" { config_path = "../vpc" }
3Terragrunt reads the producer
scratch dir, backend.tf only, init then output -json
4A real value, or a mock
mock only if the command is on the allow list
5Passed as TF_VAR_vpc_id
an env var the engine reads, then written to the app's state
The read happens while Terragrunt is still resolving the consumer's config, before the consumer's own plan starts. Whatever crosses this edge is copied into the consumer's state file.

Mocks And The Fence Around Them

Because the read comes from applied state, a brand-new environment deadlocks. You want to preview the whole stack before anything exists. Nothing exists, so there is nothing to read. Terragrunt says so in as many words, and hands you the fix in the error itself.

terminal
cd live/dev/us-east-1
terragrunt run --all plan
output
15:07:12.338 ERROR /live/dev/us-east-1/vpc/terragrunt.hcl is a dependency of /live/dev/us-east-1/app/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:
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_output = "mock-vpc-output"
}
}
For more info, see:
https://docs.terragrunt.com/features/stacks/#unapplied-dependency-and-mock-outputs
If you do not require outputs from your dependency, consider using the dependencies block instead:
https://docs.terragrunt.com/reference/config-blocks-and-attributes/#dependencies

mock_outputs breaks the deadlock with stand-in values, the way a film crew uses a stunt double so a scene can be blocked before the star turns up. Two companion settings turn that stunt double from a liability into something safe. mock_outputs_allowed_terraform_commands lists which commands are allowed to accept fake values. mock_outputs_merge_strategy_with_state decides how mocks and real state combine when both exist.

live/dev/us-east-1/app/terragrunt.hcl
dependency "vpc" {
config_path = "../vpc"
# Stand-ins, used only when the real outputs cannot be read yet.
# Obviously fake, but correctly shaped and correctly typed.
mock_outputs = {
vpc_id = "vpc-00000000000000000"
private_subnet_ids = ["subnet-00000000000000000", "subnet-11111111111111111"]
}
# A fake id must never reach a real apply or destroy
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
# Real state wins key by key; mocks only fill the gaps
mock_outputs_merge_strategy_with_state = "shallow"
}

The merge strategy is the setting people get bitten by. Its default is no_merge, which makes mocks a no-state fallback and nothing more. The instant that unit has any state at all, Terragrunt reads real outputs only and behaves as though your mock_outputs block were not there. Add a new output to an already-applied module, write a matching mock, plan the consumer, and you still get an unsupported-attribute error. shallow fixes that: real state wins on every key it provides, and mocks backfill the top-level keys it does not. deep_map_only does the same one level down inside maps, and leaves lists alone. One catch before you lean on it: the merge is gated by mock_outputs_allowed_terraform_commands as well, so if the command you are running is missing from that list, no merging happens at all. The older boolean mock_outputs_merge_with_state is deprecated in favour of the strategy setting.

A mock that reaches apply builds real infrastructure
Mocks satisfy the parser, so a renamed or misspelled output on the producer sails through plan and detonates on apply, when the mock is stripped away and the real (now missing) value is demanded. Green plan, failed apply. Worse is the case where nobody notices: if a mock value happens to match an ID that genuinely exists in the account, an apply wired to it attaches real workloads to the wrong subnet or the wrong firewall group. Keep mock_outputs_allowed_terraform_commands pinned to ["validate", "plan"], never apply or destroy, and make every mock an all-zeros value that could not possibly resolve to a live resource. Type-match them too, or plan shows phantom diffs that vanish once the dependency is applied for real, and your reviewers learn to ignore diffs.

Ordering When No Values Change Hands

Sometimes you need order without data. The scaffolders have to finish before the bricklayers start, but the scaffolders hand the bricklayers nothing. An IAM (identity and access management) role must exist before a unit that assumes it, though no value passes between them. The plural dependencies block declares exactly that: a list of paths, an ordering edge, and no output call. It reads no state, so it needs no read permission on the other unit's backend. Three narrower switches sit alongside it. skip_outputs = true on a singular dependency keeps the ordering edge and cancels the output call, falling back to mock_outputs if you set any. enabled = false drops the dependency from the run altogether, which is how you model an optional unit behind a feature flag. And the edges you get from either form are the same edges a whole-stack destroy walks in reverse.

live/prod/us-east-1/db/terragrunt.hcl
locals {
multi_region = true
}
# Pure ordering. No outputs read, no read permission needed on these backends.
dependencies {
paths = ["../vpc", "../kms"]
}
# Ordering plus a deliberate refusal to query a slow or noisy producer
dependency "audit_bucket" {
config_path = "../logging"
skip_outputs = true
}
# Only wire the replica unit in regions where it is switched on
dependency "replica" {
config_path = "../replica"
enabled = local.multi_region
}

Prove The Wiring Before You Apply

Reading HCL tells you what you meant. terragrunt render tells you what you got. It merges the include chain, resolves locals and every dependency output, then prints the flattened config. Point jq at the inputs map and you are looking at the exact values about to become variables.

terminal
cd live/prod/us-east-1/app
terragrunt render --format=json --write
jq '.inputs' terragrunt.rendered.json
output
{
"sg_ids": [
"sg-04f1c2b3a9e8d7600"
],
"subnet_ids": [
"subnet-0a1b2c3d4e5f60718",
"subnet-0f9e8d7c6b5a41302"
],
"vpc_id": "vpc-0abc12de34f567890"
}

Real IDs, not zeros, so no mock leaked in. That check earns its keep, because render is special-cased inside Terragrunt: when it cannot read a dependency's outputs for any reason, it logs a warning and quietly substitutes your mocks instead of failing. Seeing vpc-00000000000000000 in a production render is your cue to stop and find out why the real read failed. To inspect the shape of the whole stack rather than one unit, terragrunt find --dag --dependencies --format=json lists every unit in dependency order with its edges. DAG here means directed acyclic graph, a set of one-way arrows with no loops in it. terragrunt dag graph prints the same graph in DOT (the plain-text graph language Graphviz reads), so terragrunt dag graph | dot -Tsvg > stack.svg gets you a picture worth putting in a design review. If you have built a cycle by accident, this is where it surfaces, as Found a dependency cycle between modules: followed by the loop, each unit joined to the next with an arrow.

terminal
cd live/prod/us-east-1
terragrunt find --dag --dependencies --format=json
output
[
{
"type": "unit",
"path": "vpc"
},
{
"type": "unit",
"path": "security-groups",
"dependencies": [
"vpc"
]
},
{
"type": "unit",
"path": "app",
"dependencies": [
"vpc",
"security-groups"
]
}
]

Units with no edges have no dependencies key at all, which is why vpc looks bare. When a value still looks wrong after all that, add --inputs-debug. Terragrunt writes terragrunt-debug.tfvars.json next to the unit's terragrunt.hcl, holding precisely the variables it hands the engine. Run it with --log-level debug and it also prints the command to replay by hand, a tofu -chdir=<cache dir> plan -var-file=<that file> line you can paste straight into a shell. That settles the argument about whether the problem is Terragrunt's wiring or the module's own code.

Both of those files are loaded weapons, so handle them accordingly. terragrunt.rendered.json and terragrunt-debug.tfvars.json contain every dependency output in cleartext, and marking an output sensitive = true does not save you. The human-readable output command redacts a sensitive value; output -json, which is what Terragrunt calls, prints it in full. Add both filenames to .gitignore, never publish them as CI job artifacts where anyone with pipeline read access can download them, and delete them at the end of the run. A build log is a far easier target than a state bucket, and it is usually retained far longer.

What A Defender Checks

Every value crossing a dependency edge is copied into the consumer's state file. Pass a database password from an RDS (Relational Database Service, Amazon's managed database) unit into an application unit and you have not moved that secret, you have duplicated it. It now sits in a second bucket, under a second key policy, readable by a second set of roles, and it will still be sitting in an old state version long after the password is rotated. Two greps catch the common failures across a whole repository.

terminal
# 1. Units that mock outputs but never fenced which commands may use them
grep -rl 'mock_outputs' --include='terragrunt.hcl' live/ \
| xargs -r grep -L 'mock_outputs_allowed_terraform_commands'
# 2. Secret-shaped values crossing a dependency edge into another unit's state
grep -rEn 'dependency\.[a-z_]+\.outputs\.[a-z_]*(password|secret|token|private_key)' \
--include='terragrunt.hcl' live/
output
live/dev/us-east-1/app/terragrunt.hcl
live/prod/us-east-1/app/terragrunt.hcl:41: db_password = dependency.rds.outputs.master_password

The first line came from the mock check: a dev unit that defines mocks and never fenced which commands may consume them. The second came from the secret check, and it is the more serious of the two. The fix is to change what the edge carries. Publish the secret's identifier rather than the secret itself, an ARN (Amazon Resource Name, the unique identifier AWS gives every resource) or a secret name, and let the consumer's own module fetch the value at run time using its own role. The password then never enters the consumer's state, never appears in a rendered JSON (JavaScript Object Notation) file, and never lands in a build artifact. You get a detection surface out of it too, because retrieval now shows up in the audit trail as a named role calling the secret store at a predictable time, and a call from an unexpected principal or at three in the morning is something you can write an alert for.

Quick check
01Your VPC unit is already applied. You add a new output, flow_logs_group_arn, to the VPC module and add a matching key to the consumer's mock_outputs. Before re-applying the VPC, terragrunt plan on the consumer fails with an unsupported-attribute error. Why?
Incorrect — Every dependency read is fresh: Terragrunt builds a throwaway workspace, runs init and then output -json, and deletes it again, so no stale key list exists to blame.
Incorrect — That fence is real and it does gate merging, but even with plan listed the default strategy still discards the mocks the moment the producer has any state.
Correct — With no strategy set, a stand-in is only reached for when the producer has nothing applied. Set mock_outputs_merge_strategy_with_state to shallow and the gaps get filled.
Incorrect — A missing decrypt grant kills the run while Terragrunt is still resolving config, and the error names an S3 bucket; an output read never arrives half-decoded.
02An IAM (identity and access management) role must be created before a unit that assumes it, but no output value passes between them. You wire the order with a plural dependencies { paths = ["../iam"] } block instead of a singular dependency. What does that buy you?
Correct — The plural form draws the arrow and stops there, which makes it the right choice when the producer keeps its state somewhere your role cannot read.
Incorrect — No output call happens here at all, which is where the permission saving comes from; skip_outputs on a singular block cancels the call outright too.
Incorrect — Both forms produce the same edges, and a destroy follows those edges in reverse, so teardown order is identical whichever of the two you reached for.
Incorrect — find --dag --dependencies --format=json reports edges that already exist rather than creating them, and ordering is the one job the plural block exists to do.
03The defender's second grep returns live/prod/us-east-1/app/terragrunt.hcl:41: db_password = dependency.rds.outputs.master_password. The stack applies cleanly and always has. What is the reviewer objecting to, and what should change?
Incorrect — Terragrunt reads outputs with output -json, which prints a sensitive value in full, and the copy still lands in the consumer's state and in the rendered JSON.
Incorrect — That switch cancels every output from that unit, not only the risky one, and it still leaves you with no way to deliver the password to the app at all.
Incorrect — Mocks are stand-ins for outputs that do not exist yet, fenced away from apply on purpose; they are not a place to park a production credential.
Correct — One password then sits under two key policies and lingers in old state versions. Handing over an identifier keeps it in one store and gives you an audit trail.

Wire both greps into your merge-request pipeline as a hard gate. A unit that mocks outputs without fencing which commands may consume them is one careless edit away from an apply against fabricated IDs, and that edit will look like three green lines in a diff.

Try this

Run terragrunt output -json | jq 'keys' on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a mock that reaches apply builds real infrastructure. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related