PF-04 · AWS · Terragrunt
AWS Terragrunt DRY IaC
Sixteen Terraform modules behind one root config, with Terragrunt running them in order from their dependency graph.
- Role
- Design and implementation
- Period
- Personal project · released Nov 2026
- Cloud
- AWS
- Scale
- 16 modules · 16 units · 4 dependency levels
- Key tech
- Terragrunt · Terraform · AWS
- 16
- Terraform modules and units
- 1
- Root config replacing 16 copies
- - 40 %
- Deployment time (production result)
At a glance
Problem
Each Terraform root repeated its backend and provider blocks, and the order between roots lived in people's heads.
Approach
Modules stay plain Terraform and Terragrunt wraps them. The backend, provider, and common inputs are generated once from live/root.hcl. _envcommon holds module sources and dependencies, and each unit states only its inputs. dependency blocks build the graph, and mock_outputs let the whole stack validate without a cloud account.
Result
This repository groups 16 modules and 16 units into four dependency levels and validates them without cloud credentials. The 40% shorter deployment time came from moving a live production estate to this pattern.
Up close
The provider generated from the root
What this code does: Terragrunt’s generate block writes a provider.tf file into each unit folder. The region comes from the region settings, the Environment tag from the account name, and every resource automatically gets a ManagedBy = terragrunt tag. The last line merges the account and region locals into the common inputs of every unit, so a unit file keeps only the values specific to that unit.
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<PROVIDER
provider "aws" {
region = "${local.aws_region}"
default_tags {
tags = {
Environment = "${local.account_name}"
ManagedBy = "terragrunt"
}
}
}
PROVIDER
}
inputs = merge(local.account_vars.locals, local.region_vars.locals)
dependency and mock_outputs
What this code does: Declares that this unit depends on the elastic_ip unit in the neighboring folder. From this, Terragrunt runs elastic_ip first and passes its output (eip_id) into this unit as input. When no real output exists yet, the fake IDs in mock_outputs are used, but only for init, validate, and plan, so the whole stack can be validated without a cloud while fake values can never reach a real apply.
dependency "elastic_ip" {
config_path = "${get_terragrunt_dir()}/../elastic_ip"
mock_outputs = {
eip_id = {
"demo-eip-1" = "eipalloc-00000000000000001"
"demo-eip-2" = "eipalloc-00000000000000002"
}
}
mock_outputs_allowed_terraform_commands = ["init", "validate", "plan"]
}What I learned
The key point was that Terragrunt does not replace Terraform; it decides where code lives. Modules stay plain Terraform while the backend, provider, and common inputs are generated from one root, so a structure that copied the same settings 16 times became one where a single edit reaches everything. Writing run order as dependency blocks moves it from people's memory into a graph, and mock_outputs let the whole stack validate without cloud credentials. The demo keeps state local, but where several people share an environment, where state lives has to be settled first, and I now treat that as part of the design from day one.