CoursesTerragruntInstall & the terragrunt.hcl model

Install & the terragrunt.hcl model

Wrapping a Terraform module.

Advanced12 min · lesson 2 of 12

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.

terminal
cd "$(mktemp -d)"
TG_VERSION=v1.1.1
BASE="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
output
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.

terminal
curl -fsSL https://gruntwork.io/.well-known/pgp-key.txt | gpg --import
gpg --verify SHA256SUMS.gpgsig SHA256SUMS
output
gpg: key 577774ACA847CC49: public key "Gruntwork (Code Signing Key) <[email protected]>" imported
gpg: Total number processed: 1
gpg: imported: 1
gpg: Signature made Tue 14 Jul 2026 02:21:55 PM UTC
gpg: using EDDSA key 68C80F86DF98E710C0F22E2E577774ACA847CC49
gpg: 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.

terminal
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/terragrunt
ls -lh /usr/local/bin/terragrunt
output
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.

terminal
# Any PATH directory this user can write is a hijack point
echo "$PATH" | tr ':' '\n' \
| xargs -r -I{} find {} -maxdepth 0 -writable -printf '%M %u %p\n' 2>/dev/null
output
drwxrwxr-x ci /home/ci/.local/bin
drwxr-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.

mise.toml
# 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"
terminal
terragrunt --version
tofu --version
output
terragrunt version v1.1.1
OpenTofu v1.12.4
on 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.

live/prod/eu-west-1/vpc/terragrunt.hcl
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.

terminal
cd live/prod/eu-west-1/vpc
terragrunt exec -- env | grep '^TF_VAR_'
output
TF_VAR_name=prod
TF_VAR_cidr_block=10.0.0.0/16
TF_VAR_azs=["eu-west-1a","eu-west-1b","eu-west-1c"]
TF_VAR_flow_logs=true
Everything in inputs becomes an environment variable
Environment variables are readable by any process running as the same user, through /proc/<pid>/environ or ps eww, and they are the first thing to land in provider debug output and crash logs. On a shared CI (Continuous Integration, the service that builds and runs your pipelines) runner where several jobs execute under one account, a secret in inputs is a secret handed to the neighbours. Keep credentials out of inputs entirely. Let the module fetch them from a secrets manager at plan time, or give the runner a short-lived role through OIDC (OpenID Connect, the token exchange that lets a job assume a cloud role with no stored key) and let the provider pick it up on its own. Remember too that terragrunt render prints resolved values in clear text, so read its output before you paste it into a ticket.

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.

terminal
terragrunt plan
output
Initializing the backend...
Successfully configured the backend "s3"! OpenTofu will automatically
use 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 execution
plan. Resource actions are indicated with the following symbols:
+ create
OpenTofu 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.
How one terragrunt.hcl becomes an engine run
1terragrunt.hcl
source + inputs
2Resolve the source
commit fixed by ?ref=
3Download into .terragrunt-cache
everything before the //
4Copy the unit directory on top
hidden files are skipped
5Auto-init
backend + provider plugins
6tofu plan / apply
inputs arrive as TF_VAR_
One unit is one directory, one state file, one blast radius. The engine always runs from the staged copy in the cache, never from your live tree.

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.

terminal
terragrunt info print
output
{
"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.

terminal
terragrunt render --format json | jq '{source: .terraform.source, inputs}'
output
{
"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.

terminal
terragrunt find --dag
output
live/prod/eu-west-1/vpc
live/staging/eu-west-1/vpc
live/prod/eu-west-1/rds
live/staging/eu-west-1/rds
live/prod/eu-west-1/eks
Quick check
01A module calls file("${path.module}/.security_group_rules.json"). You drop that file next to your terragrunt.hcl in the unit directory, run terragrunt plan, and it still reports that the file does not exist. What happened?
Incorrect — Terragrunt pulls the whole repository sitting before the double slash, which is exactly why the double slash is required at all. Relative paths between modules keep resolving because of it.
Incorrect — The ref only decides which commit gets checked out. What lands under .terragrunt-cache is an ordinary writable directory that the engine runs inside.
Correct — Anything starting with a dot is left out of that copy. List the pattern under include_in_copy in the terraform block and it travels across with everything else.
Incorrect — file() opens a path on disk. TF_VAR_ variables feed declared module variables and have no say in which files the engine can read.
02Your runner has both tofu and terraform on PATH. The lesson pushes you to set TG_TF_PATH, or pass --tf-path, instead of letting Terragrunt decide. What risk are you removing?
Correct — Provider sources, state encryption and licence terms all differ between them, which makes this a correctness problem. Name the engine and the plan stops depending on what happened to be installed.
Incorrect — Both start fast enough that nobody is counting. What you are guarding is the shape of the plan, not the clock.
Incorrect — Nothing here is retired. When tofu is missing, Terragrunt reaches for terraform without a word, and that quiet substitution is the behaviour you want switched off.
Incorrect — Both engines talk to an S3 backend perfectly well. Their split shows up in where providers come from and how state is encrypted, not in backend support.
03gpg --verify SHA256SUMS.gpgsig SHA256SUMS prints 'Good signature from Gruntwork (Code Signing Key)' with '[unknown]' after it, then 'WARNING: This key is not certified with a trusted signature'. What do you conclude?
Incorrect — A tampered file fails the maths and reports a bad signature instead. Here the maths passed, and sha256sum -c had already answered OK on the binary.
Incorrect — The line appears because the signing key carries no trust in your keyring. Vouch for the key once and it stops appearing.
Incorrect — The checksum run already tied the binary to the list, and this signature ties the list back to Gruntwork. There is no second key to fetch.
Correct — Settle it once by comparing 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.

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.

Related