Terragrunt
Terragrunt is a thin wrapper around Terraform (and OpenTofu) that solves two problems plain Terraform leaves to copy-paste: keeping backend and provider configuration DRY across many environments, and orchestrating the order in which interdependent stacks are applied. Instead of duplicating backend blocks and shared variables in every directory, you describe them once and inherit them everywhere. As soon as you have more than a couple of environments or a handful of stacks per environment, the boilerplate and the manual ordering become a real maintenance burden — Terragrunt is the tool most teams reach for to keep that under control.
The problem Terragrunt solves
A typical multi-environment layout gives every environment its own directory with its own state file. With vanilla Terraform, each of those directories needs a near-identical backend block (differing only by the state key), the same provider configuration, and the same handful of account-wide inputs. Change your S3 bucket name or add a default_tags block and you are editing a dozen files. Terragrunt lets you generate that repeated config from a single source of truth.
It also addresses ordering. Your networking stack must apply before your database stack, which must apply before your application stack. Terraform has no concept of “apply these root modules in dependency order” — Terragrunt does, through dependency blocks and run-all.
The terragrunt.hcl file
Configuration lives in terragrunt.hcl files. A root file at the top of your repo holds shared logic; each leaf directory has a small file that points back to the root and supplies only what is unique to it.
# live/terragrunt.hcl (the root config)
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-tf-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "acme-tf-locks"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
ManagedBy = "terragrunt"
}
}
}
EOF
}
The key uses path_relative_to_include(), so every stack automatically gets a unique state path derived from its location on disk. The generate blocks write real .tf files at run time, which means the Terraform code itself stays free of backend and provider boilerplate.
A leaf stack then only needs to include the root and pick a module:
# live/prod/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "${get_repo_root()}/modules//vpc"
}
inputs = {
cidr_block = "10.20.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
Dependencies between stacks
dependency blocks let one stack consume another’s outputs without hard-coding values or wiring up terraform_remote_state by hand. Terragrunt reads the dependency’s outputs and exposes them, and it also uses these blocks to compute apply order.
# live/prod/app/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-00000000"
subnet_ids = ["subnet-0000", "subnet-1111"]
}
}
terraform {
source = "${get_repo_root()}/modules//app"
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.subnet_ids
}
The mock_outputs are used during plan/validate before the dependency has been applied, so planning doesn’t fail on a not-yet-existing upstream stack.
Running across many stacks with run-all
run-all walks the directory tree, builds a dependency graph from the dependency blocks, and runs a command against every stack in the correct order (in parallel where it can).
cd live/prod
terragrunt run-all plan
terragrunt run-all apply
Output:
Group 1
- Module live/prod/vpc
Group 2
- Module live/prod/app
aws_vpc.main: Creating...
aws_vpc.main: Creation complete after 2s [id=vpc-0a1b2c3d]
...
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Use
terragrunt run-all destroywith care — it tears down every stack under the current directory. Scope it to a single environment directory and always review the plan first.
OpenTofu users can point Terragrunt at the tofu binary with TERRAGRUNT_TFPATH=tofu (or terraform_binary = "tofu" in config); all of the above behaves identically.
When Terragrunt helps vs plain Terraform
| Situation | Plain Terraform | Terragrunt |
|---|---|---|
| One or two environments | Fine — native workspaces or copies | Overkill |
| Many environments, repeated backend config | Lots of duplication | DRY via root terragrunt.hcl |
| Cross-stack outputs | Manual terraform_remote_state | dependency blocks |
| Applying stacks in order | Manual, one dir at a time | run-all with a dependency graph |
| Generated provider/backend files | Copy-paste per dir | generate blocks |
If you only have a single root module and one or two environments, native Terraform workspaces or a simple directory split are simpler. Terragrunt earns its keep once duplication and ordering start costing you real time.
Best Practices
- Keep one root
terragrunt.hclas the single source of truth for backend and provider config, and let leaves inherit viafind_in_parent_folders(). - Derive state keys from
path_relative_to_include()so every stack gets isolated state automatically. - Always define
mock_outputsondependencyblocks soplanworks before upstream stacks exist. - Keep root modules small and single-purpose (vpc, database, app) so
run-allordering stays meaningful and blast radius stays small. - Pin the Terragrunt and Terraform/OpenTofu versions in CI to keep generated files and behavior reproducible.
- Scope
run-all destroyto a specific environment directory and review every plan before applying.