Terraform alone has no built-in answer for "the same module, deployed
three times, once per environment, with slightly different inputs and
its own state file each time." The usual result without help is three
near-identical copies of the same configuration, or one configuration
with a growing pile of count/for_each hacks
bolted on to fake having environments. Terragrunt is a thin wrapper
around Terraform that exists specifically to solve that one problem:
keep the actual infrastructure code (the module) in exactly one place,
and let each environment be a small file that says which module to use
and what inputs to give it.
infra/
├── modules/
│ └── webapp/ # the actual resource definitions, written once
│ └── main.tf
└── environments/
├── dev/
│ └── terragrunt.hcl
└── prod/
└── terragrunt.hcl
Each environment's terragrunt.hcl is small — it points
at the shared module and supplies environment-specific values, nothing
else:
# environments/prod/terragrunt.hcl
terraform {
source = "../../modules/webapp"
}
inputs = {
instance_count = 5
environment = "prod"
}
terragrunt apply run from that directory does exactly
what terraform apply would, against that module, with those
inputs — Terragrunt generates the actual Terraform invocation, it
doesn't replace the Terraform workflow itself.
include: sharing config without copy-pasting itBackend configuration, provider setup, and common variables usually
need to be identical across every environment. Repeating that block in
every single terragrunt.hcl defeats the point. A root
terragrunt.hcl at the top of the tree defines it once, and
each environment includes it:
# root terragrunt.hcl
remote_state {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
}
}
# environments/prod/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../modules/webapp"
}
inputs = {
instance_count = 5
}
path_relative_to_include() in the root config
automatically becomes each environment's own real path
(environments/prod, environments/dev, ...), so
every environment gets a correctly separated state file from the exact
same shared backend block, with zero per-environment repetition.
dependency: wiring one unit's output into anotherReal infrastructure usually isn't one flat set of independent units —
a database unit's connection string needs to reach the app unit, a VPC's
subnet IDs need to reach everything deployed into it. A
dependency block reads another unit's Terraform outputs
directly:
# environments/prod/app/terragrunt.hcl
dependency "database" {
config_path = "../database"
}
inputs = {
db_connection_string = dependency.database.outputs.connection_string
}
Terragrunt also uses these blocks to figure out apply order
automatically across a whole tree (run-all apply applies
dependencies before the units that need their outputs) — the same
relationship expressed once does double duty as both the wiring and the
ordering.
The actual infrastructure logic lives in exactly one module, reviewed and tested in one place. Every environment is a short, readable file that's obviously just "this module, these inputs" — a new environment is a new small file, not a new copy of a thousand lines of resource blocks that will inevitably drift from its siblings over time.