Install & the terragrunt.hcl model
Wrapping a Terraform module.
A shared Terraform module is a factory-built appliance. One team designs the dishwasher, tests it, and stamps out thousands of identical machines. What changes from kitchen to kitchen is never the appliance. It is the install: which water line, which socket, which settings dial. Raw Terraform makes you redraw the whole plumbing diagram in every kitchen. Terragrunt hands you a small card taped inside the cupboard door instead. The card says: fit model vpc, version v1.4.0, to these pipes, with these settings. That card is a file called terragrunt.hcl, written in HCL (HashiCorp Configuration Language, the same syntax your .tf files already use), and the directory holding it is what Terragrunt calls a unit.
Two things follow from that, and both land on whoever has to keep the estate safe. A unit is one directory, one state file, one apply, which makes it one blast radius. And the card names an exact version of an exact module, which turns that single line into a supply chain edge: whoever controls what it points at controls code that runs with your cloud admin credentials. Get the wrapper right and changes stay small and auditable. Get it wrong and there is a very short path from a compromised Git repository to a compromised account.
Install Like You Are Checking a Delivery
Terragrunt ships as one self-contained Go program, and it behaves more like a dispatcher than a builder. It works out what should run, then shells out to an engine binary (OpenTofu's tofu, or HashiCorp's terraform), and the engine is the thing that calls the cloud APIs (Application Programming Interfaces, the HTTPS endpoints AWS and friends expose over the encrypted web protocol). So you need two binaries on your PATH (the list of directories your shell searches when you type a command), not one. Terragrunt does make a few cloud calls on its own account: it can assume an IAM role for a run, and it can create or check the S3 bucket (Simple Storage Service, Amazon's object store) and the lock table that hold your state. Everything that touches your actual infrastructure goes through the engine.
The official install page offers a one-liner that pipes a script straight into a shell. Read what that sentence actually means. You hand a script you have never seen to a shell that runs it as you, with your keys and your home directory, and the only thing between you and a bad afternoon is the hope that nobody tampered with the file on the way down. For a tool that will hold administrative cloud credentials, do the slower thing. Fetch the release, check the bytes against the published list, then check who signed that list.
cd "$(mktemp -d)"TG_VERSION=v1.1.1BASE="https://github.com/gruntwork-io/terragrunt/releases/download/$TG_VERSION"# --proto '=https' --tlsv1.2 refuses any downgrade to plain HTTP or ancient TLS# (Transport Layer Security, the encryption underneath HTTPS)curl -fsSL --proto '=https' --tlsv1.2 -O "$BASE/terragrunt_linux_amd64"curl -fsSL --proto '=https' --tlsv1.2 -O "$BASE/SHA256SUMS"curl -fsSL --proto '=https' --tlsv1.2 -O "$BASE/SHA256SUMS.gpgsig"# Do the bytes match the published list?sha256sum --ignore-missing -c SHA256SUMS
terragrunt_linux_amd64: OK
A checksum on its own proves very little. It says the bytes match a list that was served from the same place as the bytes, so anyone who can rewrite the release can rewrite both. It is the delivery note the courier printed himself. What ties that list back to Gruntwork is the signature sitting beside it, and Terragrunt publishes two independent ones. SHA256SUMS.gpgsig is a detached signature from a key Gruntwork controls, checked with GPG (GNU Privacy Guard, the open source implementation of the OpenPGP signing standard). SHA256SUMS.sigstore.json is a Sigstore bundle: a short-lived certificate recording which GitHub Actions workflow produced the build, written to a public append-only log and checked with cosign (Sigstore's verification tool). Verify at least one of them. If you run GitHub CLI 2.81 or newer, gh release verify v1.1.1 --repo gruntwork-io/terragrunt does the same work in a single command.
curl -fsSL https://gruntwork.io/.well-known/pgp-key.txt | gpg --importgpg --verify SHA256SUMS.gpgsig SHA256SUMS
gpg: key 577774ACA847CC49: public key "Gruntwork (Code Signing Key) <[email protected]>" importedgpg: Total number processed: 1gpg: imported: 1gpg: Signature made Tue 14 Jul 2026 02:21:55 PM UTCgpg: using EDDSA key 68C80F86DF98E710C0F22E2E577774ACA847CC49gpg: Good signature from "Gruntwork (Code Signing Key) <[email protected]>" [unknown]gpg: WARNING: This key is not certified with a trusted signature!gpg: There is no indication that the signature belongs to the owner.Primary key fingerprint: 68C8 0F86 DF98 E710 C0F2 2E2E 5777 74AC A847 CC49
That WARNING is not a failure, and people misread it constantly. Good signature means the maths checks out: this file was signed by the key you just imported, using EdDSA (Edwards-curve Digital Signature Algorithm, the elliptic curve scheme behind modern Ed25519 keys). The [unknown] tag means something else entirely. You have never told your keyring that you trust that key, and GPG will not decide that for you. It is the difference between a signature matching the card in the envelope and knowing the card belongs to the right person. You close that gap once. Compare the printed fingerprint, 68C8 0F86 DF98 E710 C0F2 2E2E 5777 74AC A847 CC49, against the one Gruntwork publishes, then sign or locally trust the key. After that, a swapped signing key shows up as a broken signature instead of a quiet fresh import that nobody reads.
curl -fsSL --proto '=https' --tlsv1.2 -O "$BASE/SHA256SUMS.sigstore.json"cosign verify-blob SHA256SUMS \--bundle SHA256SUMS.sigstore.json \--certificate-oidc-issuer https://token.actions.githubusercontent.com \--certificate-identity-regexp "github.com/gruntwork-io/terragrunt"sudo install -o root -g root -m 0755 terragrunt_linux_amd64 /usr/local/bin/terragruntls -lh /usr/local/bin/terragrunt
Verified OK-rwxr-xr-x 1 root root 82M Jul 21 09:14 /usr/local/bin/terragrunt
The install -o root -g root -m 0755 is doing real work. The binary lands owned by root and writable only by root, so nothing running as your normal account can quietly swap it later. That matters more than it sounds. One of the cheapest persistence tricks on a build host is to drop a fake terragrunt into a directory that sits earlier on PATH and happens to be writable by the pipeline user, then wait for the next apply. Go and look for those directories. Run the check as the pipeline's own account, not as root, because root can write everywhere and the result would tell you nothing.
# Any PATH directory this user can write is a hijack pointecho "$PATH" | tr ':' '\n' \| xargs -r -I{} find {} -maxdepth 0 -writable -printf '%M %u %p\n' 2>/dev/null
drwxrwxr-x ci /home/ci/.local/bindrwxr-xr-x ci /home/ci/bin
Pin the Toolchain, Then Pin the Engine
Version skew between laptops and runners produces the slowest, dumbest incidents you will ever debug. Your machine plans clean, the runner plans a destroy, and nobody can reproduce either. Pin both binaries in a file you commit. mise (a version manager that reads a mise.toml from your repository root and puts the right binaries on PATH) is the least friction option, and a cold container ends up with the same toolchain as a five-year-old workstation. Run mise install once after cloning and both tools appear. The first time it reads a new config file mise asks you to trust it, which is a small supply chain check of its own.
# repo root, committed alongside the live/ tree[tools]terragrunt = "1.1.1"opentofu = "1.12.4"[env]# Two engines on PATH is ambiguous. Say which one you mean.TG_TF_PATH = "tofu"
terragrunt --versiontofu --version
terragrunt version v1.1.1OpenTofu v1.12.4on linux_amd64
Terragrunt looks for a binary named tofu first. If tofu is not there and terraform is, it picks up terraform and carries on. That fallback is documented and quiet, and one day it will cost you an afternoon, because OpenTofu and Terraform have drifted apart. OpenTofu can encrypt its state file in ways Terraform cannot read. The two pull providers from different registries. The licences differ. The same unit can plan differently under each one. Setting TG_TF_PATH, or the matching --tf-path flag, ends the guessing, and the documentation is blunt about how far that reaches: it overrides the engine in all instances, including dependency lookups into other units, and it beats any terraform_binary value set inside the configuration.
One Directory, One Card, One Blast Radius
A unit is any directory holding a terragrunt.hcl. Two pieces of that file carry the whole idea. A terraform block with a source attribute says which module to run. An inputs map says what to feed it. The source uses Terraform's own module address syntax, which reads as four parts glued together: a scheme prefix such as git::, the repository address, a double slash marking where the module starts inside that repository, and a ?ref= naming the exact tag or commit to check out.
terraform {# git:: fetch over SSH (Secure Shell), using the runner's deploy key# //vpc the module's subdirectory inside that repository# ?ref= an immutable tag (a branch name here is a moving target)source = "git::ssh://[email protected]/acme/tf-modules.git//vpc?ref=v1.4.0"}inputs = {name = "prod"cidr_block = "10.0.0.0/16"azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]flow_logs = true}
That card builds one VPC (Virtual Private Cloud, your own walled-off network inside the cloud account), and the ?ref= is the security-relevant line in it. Point it at v1.4.0 and every run resolves the same tree, so the plan you reviewed on Tuesday is the plan that applies on Thursday. Point it at main and the module can change between review and apply, with no diff in your repository to show for it. Tags are not truly immutable either, because anyone who can push to the module repository can move one. If that repository does not sit under the same review controls as your live tree, pin to a full commit SHA (Secure Hash Algorithm digest, the forty character identifier Git gives every commit) and require signed tags. A local path like ../../modules/vpc is fine while you are still writing the module, and wrong the moment a second team depends on it.
Now the part that surprises people. inputs are not written into a .tfvars file, which is where Terraform normally keeps variable values. Terragrunt exports each entry as an environment variable named TF_VAR_<name> and hands that environment to the engine process. Strings cross over as plain text. Lists, maps and numbers are JSON encoded (JavaScript Object Notation, a plain text format for structured data), and the type information is lost on the way, so the module has to declare the type it expects or it will read your list as a string. You can watch all of this happen, because terragrunt exec runs any command you like inside exactly the environment a real run would receive.
cd live/prod/eu-west-1/vpcterragrunt exec -- env | grep '^TF_VAR_'
TF_VAR_name=prodTF_VAR_cidr_block=10.0.0.0/16TF_VAR_azs=["eu-west-1a","eu-west-1b","eu-west-1c"]TF_VAR_flow_logs=true
Where the Engine Actually Runs
Running a unit looks like running the engine. Change into the directory and type terragrunt where you would have typed tofu. What differs is everything that happens before the engine starts. Terragrunt downloads the source into a .terragrunt-cache/ directory inside the unit, and it downloads everything before the double slash, meaning the whole repository rather than only the module folder. That is deliberate, and it is why the double slash is required: it keeps relative paths between modules in that repository working. Terragrunt then copies the contents of your unit directory in on top, writes any generated files there, and starts the engine in the subdirectory named after the double slash. Initialisation is automatic, so there is no separate init step unless you turn it off with --no-auto-init.
terragrunt plan
Initializing the backend...Successfully configured the backend "s3"! OpenTofu will automaticallyuse this backend unless the backend configuration changes.Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 6.0"...- Installing hashicorp/aws v6.55.0...- Installed hashicorp/aws v6.55.0 (signed, key ID 0C0AF313E5FD9F80)OpenTofu has been successfully initialized!OpenTofu used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:+ createOpenTofu will perform the following actions:# aws_vpc.this will be created+ resource "aws_vpc" "this" {+ arn = (known after apply)+ cidr_block = "10.0.0.0/16"+ enable_dns_hostnames = true+ id = (known after apply)+ tags_all = {+ "Name" = "prod"}}Plan: 4 to add, 0 to change, 0 to destroy.
That copy step has one rule worth memorising: hidden files and folders in your unit directory are left behind. A .tflint.hcl, a .env, a .security_group_rules.json sitting right next to your terragrunt.hcl never reaches the working directory, so the module fails to find a file you can see with your own eyes. The escape hatch is include_in_copy inside the terraform block, which takes a list of glob patterns to carry over anyway. Two more habits save time here. Add .terragrunt-cache/ to .gitignore, because it holds downloaded provider binaries, grows to hundreds of megabytes, and committing it would drop third-party executables into your source tree. And when a source change appears to do nothing at all, suspect a stale cache: clear it with find . -type d -name '.terragrunt-cache' -prune -exec rm -rf {} + or force a refetch with terragrunt run --source-update -- plan.
When something looks wrong, ask Terragrunt what it believes before you start guessing. terragrunt info print dumps the resolved context as JSON: which config file it read, where the cache lives, which engine binary it picked, and which role it will assume.
terragrunt info print
{"config_path": "/home/ci/live/prod/eu-west-1/vpc/terragrunt.hcl","download_dir": "/home/ci/live/prod/eu-west-1/vpc/.terragrunt-cache","iam_role": "","terraform_binary": "tofu","terraform_command": "print","working_dir": "/home/ci/live/prod/eu-west-1/vpc"}
Three of those fields settle arguments. terraform_binary tells you whether you got tofu or the quiet terraform fallback. config_path tells you which file won when several could have applied. iam_role tells you whose permissions the run will carry, under IAM (Identity and Access Management, the cloud's permission system), and that is the line to capture when someone asks how a pipeline was able to delete a production bucket.
Prove the Wrapper Did What You Meant
Reviewing a terragrunt.hcl means reviewing a promise, not a result. Includes, locals and dependency lookups can all rewrite the final values, and in a real repository most units inherit half their configuration from a parent file they never mention by name. terragrunt render collapses all of it and prints the configuration that will actually be used. Run it as a pull request check and diff it against the same command on the base branch, piping through jq (a small command-line filter for JSON) to keep the noise down. A change that quietly repoints a module or widens a CIDR (Classless Inter-Domain Routing block, the 10.0.0.0/16 style way of writing an address range) stops being invisible.
terragrunt render --format json | jq '{source: .terraform.source, inputs}'
{"source": "git::ssh://[email protected]/acme/tf-modules.git//vpc?ref=v1.4.0","inputs": {"azs": ["eu-west-1a","eu-west-1b","eu-west-1c"],"cidr_block": "10.0.0.0/16","flow_logs": true,"name": "prod"}}
When you need to know what units exist at all, rather than what one of them claims about itself, terragrunt find walks the tree and lists them. The --dag flag sorts by the DAG (Directed Acyclic Graph, the dependency order Terragrunt derives from how units reference each other) instead of alphabetically, so dependencies come before the things that depend on them and the output doubles as the order an apply would take. Point it at your live directory the morning of an audit and you have the full inventory of state units, plus their sequencing, in one command.
terragrunt find --dag
live/prod/eu-west-1/vpclive/staging/eu-west-1/vpclive/prod/eu-west-1/rdslive/staging/eu-west-1/rdslive/prod/eu-west-1/eks
Try this
Run curl -fsSL --proto '=https' --tlsv1.2 -O "$BASE/terragrunt_linux_amd64" 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: everything in inputs becomes an environment variable. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.