Dependencies & outputs
Wire modules together safely.
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.
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 outputsdependency "vpc" {config_path = "../vpc"}# Read the firewall unit's published outputsdependency "sg" {config_path = "../security-groups"}inputs = {vpc_id = dependency.vpc.outputs.vpc_idsubnet_ids = dependency.vpc.outputs.private_subnet_idssg_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.
cd live/prod/us-east-1/vpc# Terragrunt's own log lines go to stderr, so the pipe into jq stays cleanterragrunt output -json | jq 'keys'
["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.
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.
cd live/dev/us-east-1terragrunt run --all plan
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-outputsIf 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.
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 destroymock_outputs_allowed_terraform_commands = ["validate", "plan"]# Real state wins key by key; mocks only fill the gapsmock_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.
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.
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 producerdependency "audit_bucket" {config_path = "../logging"skip_outputs = true}# Only wire the replica unit in regions where it is switched ondependency "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.
cd live/prod/us-east-1/appterragrunt render --format=json --writejq '.inputs' terragrunt.rendered.json
{"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.
cd live/prod/us-east-1terragrunt find --dag --dependencies --format=json
[{"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.
# 1. Units that mock outputs but never fenced which commands may use themgrep -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 stategrep -rEn 'dependency\.[a-z_]+\.outputs\.[a-z_]*(password|secret|token|private_key)' \--include='terragrunt.hcl' live/
live/dev/us-east-1/app/terragrunt.hcllive/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.
output -json, and deletes it again, so no stale key list exists to blame.mock_outputs_merge_strategy_with_state to shallow and the gaps get filled.dependencies { paths = ["../iam"] } block instead of a singular dependency. What does that buy you?skip_outputs on a singular block cancels the call outright too.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.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?output -json, which prints a sensitive value in full, and the copy still lands in the consumer's state and in the rendered JSON.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.