Root configs, account configs, environment configs. One value, defined once, available everywhere. No copy-paste.
I had 12 environment directories. Each one had the same AWS region defined. Each one had the same tags. Each one had the same KMS key configuration
Change the region? Update 12 files. Add a new tag? Update 12 files. Miss one? That environment breaks differently
Terragrunt has a hierarchy system that fixes this. Define values once at the right level, they cascade down to everything below
The hierarchy that makes sense
Think about your infrastructure values. Some apply everywhere. Some apply to an AWS account. Some apply to a specific environment. Some apply to a region
root.hcl → Everything: backend config, provider setup, project-wide tags
account.hcl → AWS account-specific: account ID, IAM roles, cost center
environment.hcl → Environment-specific: prod vs staging configs, domain names
region.hcl → Region-specific: availability zones, regional endpoints Each level inherits from the one above it. The database module in prod/us-east-1/database/ automatically gets values from all four levels
No duplication. No copy-paste. One source of truth per value
Root config: the foundation
root.hcl sits at the top of your infrastructure directory. It defines what every single resource shares:
# infrastructure/root.hcl
locals {
project_name = "myapp"
project_id = "app123"
# These tags go on every resource
root_tags = {
"project" = "myapp"
"managed-by" = "terraform"
"cost-center" = "engineering"
}
}
# Read child configs
locals {
account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl"))
environment_vars = read_terragrunt_config(find_in_parent_folders("environment.hcl"))
region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
}
# Backend for all modules
remote_state {
backend = "s3"
config = {
bucket = "myapp-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
# Provider config generated for every module
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<-EOF
provider "aws" {
region = "${local.region_vars.locals.region}"
assume_role {
role_arn = "${local.account_vars.locals.iam_role}"
}
default_tags {
tags = var.default_tags
}
}
EOF
}
# Make everything available as inputs
inputs = merge(
local.account_vars.locals,
local.environment_vars.locals,
local.region_vars.locals,
{
project_name = local.project_name
default_tags = merge(
local.root_tags,
local.account_vars.locals.account_tags,
local.environment_vars.locals.environment_tags
)
}
) Write your backend config once. Your provider config once. Your root tags once
Every module gets them automatically. Change the S3 bucket? One line. Change a tag? One line
Account config: AWS-specific stuff
account.hcl defines what’s specific to an AWS account. You have different accounts for prod and non-prod? Each account has its own file:
# infrastructure/prod-account/account.hcl
locals {
account_id = "123456789012"
account_name = "production"
# IAM role to assume for deployments
iam_role = "arn:aws:iam::123456789012:role/terraform-deploy"
# Cost tracking tags specific to this account
account_tags = {
"environment" = "production"
"account-id" = "123456789012"
"criticality" = "high"
}
# Prod-specific config
enable_deletion_protection = true
backup_retention_days = 90
} # infrastructure/staging-account/account.hcl
locals {
account_id = "987654321098"
account_name = "staging"
iam_role = "arn:aws:iam::987654321098:role/terraform-deploy"
account_tags = {
"environment" = "staging"
"account-id" = "987654321098"
"criticality" = "medium"
}
enable_deletion_protection = false
backup_retention_days = 30
} The account ID, IAM role, and account-level policies are defined once. Every resource in that account gets them
Change the IAM role for prod? One file. Add account-level tags? One file
Environment config: prod vs staging differences
environment.hcl defines what’s different between prod and staging within the same account:
# infrastructure/prod-account/prod/environment.hcl
locals {
environment = "prod"
environment_tags = {
"environment" = "prod"
}
# Prod domain
domain_name = "myapp.com"
# Prod sizing
database_instance_class = "db.r6g.xlarge"
cache_node_type = "cache.r6g.large"
# Prod scaling
min_instances = 3
max_instances = 10
# Prod monitoring
enable_detailed_monitoring = true
alarm_email = "oncall@company.com"
} # infrastructure/prod-account/staging/environment.hcl
locals {
environment = "staging"
environment_tags = {
"environment" = "staging"
}
domain_name = "staging.myapp.com"
# Smaller for staging
database_instance_class = "db.t4g.medium"
cache_node_type = "cache.t4g.small"
min_instances = 1
max_instances = 3
enable_detailed_monitoring = false
alarm_email = "dev-team@company.com"
} The differences between prod and staging are explicit. Instance sizes, domain names, scaling parameters
All your prod resources get prod values. All your staging resources get staging values. No mixing them up
Region config: geographic stuff
region.hcl defines what’s specific to a region:
# infrastructure/prod-account/prod/us-east-1/region.hcl
locals {
region = "us-east-1"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
# Regional endpoints
regional_domain = "use1.myapp.com"
} # infrastructure/prod-account/prod/eu-west-1/region.hcl
locals {
region = "eu-west-1"
availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
regional_domain = "euw1.myapp.com"
} Each region has its own config. Deploy to a new region? Copy the region directory, update region.hcl
Everything else cascades from above. The account config, environment config, root config all apply automatically
Module config: the specific stuff
At the bottom of the hierarchy, each module has a terragrunt.hcl:
# infrastructure/prod-account/prod/us-east-1/database/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "${get_repo_root()}/modules/database"
}
dependency "network" {
config_path = "../network"
}
inputs = {
# Module-specific values
db_name = "myapp_prod"
multi_az = true
# Everything else comes from the hierarchy:
# - region from region.hcl
# - database_instance_class from environment.hcl
# - backup_retention_days from account.hcl
# - default_tags from root.hcl
vpc_id = dependency.network.outputs.vpc_id
subnet_ids = dependency.network.outputs.database_subnet_ids
} The module config is tiny. It only defines what’s unique to this specific database
Everything else? Already defined higher up. The hierarchy provides it automatically
How values cascade
When you run terragrunt apply in prod-account/prod/us-east-1/database/:
- Terragrunt finds
root.hcland loads project-wide config - Loads
account.hclfor account-specific values - Loads
environment.hclfor prod vs staging differences - Loads
region.hclfor regional settings - Merges everything in
inputs
The database module receives:
project_namefrom rootaccount_id,iam_role,backup_retention_daysfrom accountenvironment,database_instance_classfrom environmentregion,availability_zonesfrom regiondb_name,multi_azfrom the module itself
One variable, defined once, at the right level. No duplication
The merge strategy
The inputs block in root.hcl merges everything:
inputs = merge(
local.account_vars.locals,
local.environment_vars.locals,
local.region_vars.locals,
{
project_name = local.project_name
default_tags = merge(
local.root_tags,
local.account_vars.locals.account_tags,
local.environment_vars.locals.environment_tags
)
}
) Tags get merged. Account tags override root tags. Environment tags override account tags
You want global tags? Put them in root. Account-specific? Put them in account. Environment-specific? Put them in environment
Directory structure
infrastructure/
├── root.hcl
├── modules/
│ ├── database/
│ ├── cache/
│ └── app/
├── prod-account/
│ ├── account.hcl
│ ├── prod/
│ │ ├── environment.hcl
│ │ ├── us-east-1/
│ │ │ ├── region.hcl
│ │ │ ├── database/
│ │ │ │ └── terragrunt.hcl
│ │ │ ├── cache/
│ │ │ │ └── terragrunt.hcl
│ │ │ └── app/
│ │ │ └── terragrunt.hcl
│ │ └── eu-west-1/
│ │ ├── region.hcl
│ │ ├── database/
│ │ └── ...
│ └── staging/
│ ├── environment.hcl
│ └── us-east-1/
│ └── ...
└── staging-account/
├── account.hcl
└── ... Clear hierarchy. You know where to find things. Change account-level config? account.hcl. Change prod config? environment.hcl
No searching through 47 files to find where that value is defined
Shared module configs
Sometimes multiple modules need the same config. Create _envcommon/:
# infrastructure/_envcommon/database.hcl
terraform {
source = "${get_repo_root()}/modules/database"
}
locals {
# Read environment config to find network dependencies
environment_vars = read_terragrunt_config(
find_in_parent_folders("environment.hcl")
)
}
dependency "network" {
config_path = "../network"
}
inputs = {
vpc_id = dependency.network.outputs.vpc_id
subnet_ids = dependency.network.outputs.database_subnet_ids
} Then each database instance includes it:
# prod-account/prod/us-east-1/database/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
include "envcommon" {
path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/database.hcl"
}
inputs = {
db_name = "myapp_prod"
multi_az = true
} The common config is shared. The specific config is local. No duplication
Real example: changing regions
You’re deploying to eu-west-1. Copy the region directory:
cp -r prod-account/prod/us-east-1 prod-account/prod/eu-west-1 Edit one file:
# prod-account/prod/eu-west-1/region.hcl
locals {
region = "eu-west-1"
availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
} Run it:
cd prod-account/prod/eu-west-1
terragrunt run-all apply Every module in that region gets:
- The new region automatically
- The same account config
- The same environment config
- The same root config
You changed one value. Everything else cascaded
What this fixes
Before Terragrunt hierarchy, I had:
- AWS region defined in 12 places
- Backend config copy-pasted everywhere
- Tags duplicated across all environments
- IAM roles scattered through configs
- No idea which value should go where
After:
- Region in
region.hcl, once - Backend in
root.hcl, once - Tags merged from root, account, environment
- IAM role in
account.hcl, once - Clear hierarchy, obvious where values belong
I changed our prod AWS account ID. One file. Took 10 seconds
Before this? I would have grepped through 47 files, updated each one, missed two, broken staging, spent an hour debugging
Common patterns
Cross-environment dependencies: Prod uses staging’s VPC for VPN access:
# prod-account/prod/environment.hcl
locals {
network_dependency_path = "${get_repo_root()}/infrastructure/staging-account/staging/us-east-1/network"
} Dynamic prefixes: Generate resource names from hierarchy values:
# root.hcl
locals {
prefix = "${local.project_id}-${local.environment_vars.locals.environment}-${local.region_vars.locals.region}"
}
inputs = {
resource_prefix = local.prefix
} Every resource in prod/us-east-1 gets prefix app123-prod-us-east-1. Every resource in staging/eu-west-1 gets app123-staging-eu-west-1. No conflicts
Conditional config: Enable features only in certain environments:
# prod-account/prod/environment.hcl
locals {
enable_waf = true
enable_enhanced_monitoring = true
}
# staging-account/staging/environment.hcl
locals {
enable_waf = false
enable_enhanced_monitoring = false
} When this doesn’t help
Small projects with one environment. The hierarchy is overkill. Just use a single terragrunt.hcl
Wildly different infrastructure per environment. If prod and staging have nothing in common, the hierarchy doesn’t buy you much
Projects where every module is a special snowflake. If nothing is reusable, there’s nothing to cascade
Getting started
Add hierarchy to existing Terragrunt setup:
- Move backend config to
root.hcl - Create
account.hclwith account-specific values - Create
environment.hclwith environment-specific values - Create
region.hclwith regional values - Update
root.hclto read and merge them
Start with one value. Move AWS region from every module to region.hcl. See it work
Then move tags. Then IAM roles. One value at a time
After a week you’ll have config that makes sense. Values defined once. Clear hierarchy. No duplication
I spent 6 months copy-pasting Terraform config. I spent 2 days setting up this hierarchy. I’m never going back
Enjoyed this article?
Let me know! A share is always appreciated.