That is exactly the problem Terragrunt was built to solve.
In this post I'll introduce what Terragrunt is, its benefits over plain Terraform, and walk you through a lab that runs entirely on your local machine — no AWS, GCP, or any cloud provider needed.
1. The problem with Terraform at scale
Imagine you have an infrastructure made up of 3 modules:
network— manages the VPC, subnetsdatabase— manages RDS, database instancesapp— manages EC2, ECS, or any compute
And you need to deploy to 2 environments: dev and prod.
With plain Terraform, your directory structure looks like this:
infrastructure/
├── dev/
│ ├── network/
│ │ ├── main.tf
│ │ ├── backend.tf ← duplicated
│ │ └── provider.tf ← duplicated
│ ├── database/
│ │ ├── main.tf
│ │ ├── backend.tf ← duplicated
│ │ └── provider.tf ← duplicated
│ └── app/
│ ├── main.tf
│ ├── backend.tf ← duplicated
│ └── provider.tf ← duplicated
└── prod/
├── network/
│ ├── main.tf
│ ├── backend.tf ← duplicated
│ └── provider.tf ← duplicated
├── database/
│ ├── main.tf
│ ├── backend.tf ← duplicated
│ └── provider.tf ← duplicated
└── app/
├── main.tf
├── backend.tf ← duplicated
└── provider.tf ← duplicated
2 environments × 3 modules = 6 copies of backend.tf and provider.tf. When you need to change the S3 bucket that stores state, you have to edit all 6 files. When you add a new environment, you copy 3 more files.
This is "copy-paste infrastructure" — one of the biggest problems when scaling Terraform.
2. What is Terragrunt?
Terragrunt is a thin wrapper on top of Terraform, developed by Gruntwork. It doesn't replace Terraform — it adds features that address Terraform's weaknesses at scale.
Put simply: Terraform manages infrastructure, and Terragrunt manages your Terraform configuration files.
Quick comparison
| Aspect | Plain Terraform | Terraform + Terragrunt |
|---|---|---|
| Backend config | Duplicated in every module | Defined once at the root |
| Provider config | Duplicated in every module | Auto-generated for every module |
| Dependencies between modules | Manual, via terraform_remote_state | dependency block, automatic |
| Applying multiple modules | Must run each module in order | terragrunt run --all apply |
| Environment variables | Copy/paste with small changes | Read from a shared env.hcl |
3. Terragrunt's main features
3.1 DRY — Don't Repeat Yourself
This is the core benefit. Terragrunt lets you define the backend, provider, and common inputs in a single root file (live/terragrunt.hcl). Every child module inherits it via include "root".
3.2 run --all — Apply/Destroy multiple modules at once
# Apply ALL modules in the current directory and subdirectories
terragrunt run --all apply
# Destroy ALL
terragrunt run --all destroy
Terragrunt automatically analyzes the dependency graph and applies things in the correct order.
3.3 Dependency management
dependency "network" {
config_path = "../network"
}
inputs = {
# Take the network module's output and pass it into the database module
network_id = dependency.network.outputs.network_id
}
No need for terraform_remote_state. Terragrunt reads the output directly and guarantees the correct apply order.
3.4 The generate block
Automatically creates .tf files in each module's working directory at runtime. Commonly used to generate provider.tf and backend.tf.
3.5 Built-in functions
Terragrunt ships with a number of handy functions:
find_in_parent_folders("root.hcl")— find aroot.hclfile in a parent directoryread_terragrunt_config()— read any HCL file into localsget_terragrunt_dir()— get the absolute path of the directory containing the currentterragrunt.hclpath_relative_to_include()— path relative to the root include
4. Lab architecture
This lab simulates deploying a 3-component application to 2 environments (dev and prod). To avoid depending on the cloud, each "resource" simply writes out a local JSON file — but the structure and workflow are exactly the same as in production.
4.1 Directory structure
terragrunt-lab/
├── terraform-modules/ # Plain Terraform modules (know nothing about Terragrunt)
│ ├── network/
│ │ ├── main.tf # Creates network.json
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── database/
│ │ ├── main.tf # Creates database.json
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── app/
│ ├── main.tf # Creates app.json
│ ├── variables.tf
│ └── outputs.tf
│
└── live/ # Terragrunt configuration layer
├── root.hcl # ROOT: backend + provider + common inputs
├── dev/
│ ├── env.hcl # Variables specific to dev
│ ├── network/terragrunt.hcl
│ ├── database/terragrunt.hcl
│ └── app/terragrunt.hcl
└── prod/
├── env.hcl # Variables specific to prod
├── network/terragrunt.hcl
├── database/terragrunt.hcl
└── app/terragrunt.hcl
4.2 Dependency graph
network ──────────────────┐
└──→ database ────────┤
└──→ app
The database module depends on network (it needs network_id).
The app module depends on both network and database.
When you run terragrunt run --all apply, Terragrunt automatically resolves this graph and applies things in the correct order.
5. Installation
Install Terraform
# Ubuntu / Debian
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# macOS
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
Install Terragrunt
# macOS / Linux (using brew)
brew install terragrunt
# Or download the binary directly from GitHub Releases
# https://github.com/gruntwork-io/terragrunt/releases
# Example for Linux amd64:
curl -Lo terragrunt https://github.com/gruntwork-io/terragrunt/releases/latest/download/terragrunt_linux_amd64
chmod +x terragrunt
sudo mv terragrunt /usr/local/bin/
Verify
terraform version # >= 1.0
terragrunt --version # >= 0.50
6. Step 1 — Create the Terraform modules
These modules are plain Terraform — they know nothing about Terragrunt. This is best practice: write independent modules, then use Terragrunt to orchestrate them.
The network module
This module takes in network parameters and "creates" a network configuration (writes out a JSON file).
terraform-modules/network/main.tf
resource "local_file" "network_config" {
content = jsonencode({
network_id = "${var.app_name}-${var.environment}-network"
cidr_block = var.cidr_block
subnets = var.subnets
environment = var.environment
})
filename = "${var.output_path}/network.json"
file_permission = "0644"
}
terraform-modules/network/variables.tf
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
}
variable "app_name" {
description = "Application name"
type = string
}
variable "cidr_block" {
description = "CIDR block for the virtual network"
type = string
default = "10.0.0.0/16"
}
variable "subnets" {
description = "List of subnet CIDR blocks"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "output_path" {
description = "Local path to write the generated config file"
type = string
}
terraform-modules/network/outputs.tf
output "network_id" {
value = "${var.app_name}-${var.environment}-network"
}
output "cidr_block" {
value = var.cidr_block
}
The database module
The database module depends on network_id — in plain Terraform this is just a variable. Terragrunt will automatically pull this value from the network module's output.
terraform-modules/database/main.tf
resource "local_file" "database_config" {
content = jsonencode({
db_id = "${var.app_name}-${var.environment}-db"
db_name = var.db_name
db_engine = var.db_engine
db_size = var.db_size
network_id = var.network_id
environment = var.environment
})
filename = "${var.output_path}/database.json"
file_permission = "0644"
}
terraform-modules/database/outputs.tf
output "db_id" {
value = "${var.app_name}-${var.environment}-db"
}
output "db_endpoint" {
value = "${var.app_name}-${var.environment}-db.local:5432"
}
The app module
The app module takes network_id plus db_id and db_endpoint from the two modules above.
terraform-modules/app/main.tf
resource "local_file" "app_config" {
content = jsonencode({
app_id = "${var.app_name}-${var.environment}"
app_version = var.app_version
instance_type = var.instance_type
replicas = var.replicas
network_id = var.network_id
db_id = var.db_id
db_endpoint = var.db_endpoint
environment = var.environment
app_url = "http://${var.app_name}-${var.environment}.local"
})
filename = "${var.output_path}/app.json"
file_permission = "0644"
}
7. Step 2 — Configure Terragrunt
7.1 root.hcl — The heart of DRY
This is the most important file. Define once, inherit everywhere.
live/root.hcl
# =============================================================================
# ROOT root.hcl — Shared configuration inherited by ALL modules
# =============================================================================
locals {
app_name = "myapp"
}
# DRY BENEFIT #1 — Remote state backend defined ONCE
# No more backend.tf needed in every module!
# Path: live/<env>/<module>/terraform.tfstate
remote_state {
backend = "local"
config = {
path = "${get_parent_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate"
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
# DRY BENEFIT #2 — Provider configuration generated ONCE
# Terragrunt auto-creates the provider.tf file for every module
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<-EOF
terraform {
required_version = ">= 1.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
EOF
}
# DRY BENEFIT #3 — Common inputs automatically passed down to ALL modules
inputs = {
app_name = local.app_name
}
Notice a few Terragrunt functions used here:
| Function | Meaning |
|---|---|
get_parent_terragrunt_dir() | Absolute path of the directory containing the root terragrunt.hcl (i.e. live/) |
path_relative_to_include() | Path of the current module relative to the root (e.g. dev/network) |
Put together, the state file for live/dev/network will be stored at: live/dev/network/terraform.tfstate ✓
7.2 env.hcl — Per-environment variables
live/dev/env.hcl
locals {
environment = "dev"
cidr_block = "10.0.0.0/16"
instance_type = "small"
db_size = "small"
replicas = 1
}
live/prod/env.hcl
locals {
environment = "prod"
cidr_block = "10.1.0.0/16"
instance_type = "large"
db_size = "large"
replicas = 3
}
These two files have the exact same structure, just different values. This is how Terragrunt guarantees environment parity.
7.3 The module's terragrunt.hcl — Inherit and extend
live/dev/network/terragrunt.hcl
locals {
# Read the current environment's env.hcl
env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}
# Inherit the full config from live/root.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
# Specify which Terraform module to use
terraform {
source = "../../../terraform-modules/network"
}
# Inputs specific to this module + common inputs from root
inputs = {
environment = local.env_vars.locals.environment
cidr_block = local.env_vars.locals.cidr_block
subnets = ["${cidrsubnet(local.env_vars.locals.cidr_block, 8, 1)}", "${cidrsubnet(local.env_vars.locals.cidr_block, 8, 2)}"]
output_path = "${get_terragrunt_dir()}/output"
}
live/dev/database/terragrunt.hcl
locals {
env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../../../terraform-modules/database"
}
# DEPENDENCY: database needs to know whether network has already been created
# Terragrunt will make sure network is applied BEFORE database
dependency "network" {
config_path = "../network"
# Mock values used when running validate/plan/destroy before network has been applied
mock_outputs = {
network_id = "mock-network-id"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "destroy"]
}
inputs = {
environment = local.env_vars.locals.environment
db_size = local.env_vars.locals.db_size
# Pull the network module's output — Terragrunt reads network's state automatically
network_id = dependency.network.outputs.network_id
output_path = "${get_terragrunt_dir()}/output"
}
live/dev/app/terragrunt.hcl
locals {
env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../../../terraform-modules/app"
}
# App depends on BOTH network and database
dependency "network" {
config_path = "../network"
mock_outputs = {
network_id = "mock-network-id"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "destroy"]
}
dependency "database" {
config_path = "../database"
mock_outputs = {
db_id = "mock-db-id"
db_endpoint = "mock-db.local:5432"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "destroy"]
}
inputs = {
environment = local.env_vars.locals.environment
instance_type = local.env_vars.locals.instance_type
replicas = local.env_vars.locals.replicas
network_id = dependency.network.outputs.network_id
db_id = dependency.database.outputs.db_id
db_endpoint = dependency.database.outputs.db_endpoint
output_path = "${get_terragrunt_dir()}/output"
}
Note: The
terragrunt.hclfiles forprod/network,prod/database,prod/apphave the exact same structure and content asdev/. No difference at all! Why? Because the difference between the two environments is entirely pushed intoenv.hcl. This is the power of Terragrunt.
8. Step 3 — Run the lab
8.1 Clone the repo and inspect the structure
git clone https://github.com/MMA17/terragrunt-lab
cd terragrunt-lab
8.2 Apply the dev environment
cd live/dev
# Apply all modules (network → database → app) with a single command
terragrunt run --all apply
Terragrunt will ask for confirmation:
Are you sure you want to run 'terragrunt apply' in each folder of the stack described above? (y/n)
Type y and watch the apply order: network first, then database, and finally app.
8.3 Check the output
Once the apply finishes, you'll see the generated files:
cat live/dev/network/output/network.json
{
"network_id": "myapp-dev-network",
"cidr_block": "10.0.0.0/16",
"subnets": ["10.0.1.0/24", "10.0.2.0/24"],
"environment": "dev"
}
cat live/dev/database/output/database.json
{
"db_id": "myapp-dev-db",
"db_name": "appdb",
"db_engine": "postgres",
"db_size": "small",
"network_id": "myapp-dev-network",
"environment": "dev"
}
cat live/dev/app/output/app.json
{
"app_id": "myapp-dev",
"app_version": "1.0.0",
"instance_type": "small",
"replicas": 1,
"network_id": "myapp-dev-network",
"db_id": "myapp-dev-db",
"db_endpoint": "myapp-dev-db.local:5432",
"environment": "dev",
"app_url": "http://myapp-dev.local"
}
Note that network_id and db_id in app.json match the outputs of the two preceding modules — this is the result of dependency management.
8.4 Apply the prod environment
cd live/prod
terragrunt run --all apply
cat live/prod/app/output/app.json
{
"app_id": "myapp-prod",
"app_version": "1.0.0",
"instance_type": "large",
"replicas": 3,
"network_id": "myapp-prod-network",
"db_id": "myapp-prod-db",
"db_endpoint": "myapp-prod-db.local:5432",
"environment": "prod",
"app_url": "http://myapp-prod.local"
}
instance_type: "large" and replicas: 3 — matching prod/env.hcl. Same module, different configuration, no code copying needed.
8.5 A few other handy Terragrunt commands
# Show the plan for all modules
terragrunt run --all plan
# Validate the config (uses mock_outputs for dependencies)
terragrunt run --all validate
# Show a single module's output
cd live/dev/network
terragrunt output
# Destroy everything (prod first, then dev, if you want to be safe)
cd live/prod && terragrunt run --all destroy
cd live/dev && terragrunt run --all destroy
9. Summary — What you've learned
Through this lab, you've seen what problems Terragrunt solves:
| Problem | Terragrunt's solution |
|---|---|
backend.tf duplicated across 6 modules | A remote_state block at the root, auto-generated |
provider.tf duplicated across 6 modules | A generate "provider" block at the root |
| Must apply manually in order | run --all apply with an automatic dependency graph |
| Copying env config when adding an environment | env.hcl holds the variables, the module's terragrunt.hcl is shared |
| Pulling another module's output | A dependency block, no terraform_remote_state needed |
When should you use Terragrunt?
- You have more than 1 environment (dev/staging/prod)
- You have many Terraform modules that need orchestrating
- You want to enforce consistency across environments
- Your team wants to minimize copy-paste in IaC
When do you NOT need Terragrunt?
- Small project, only 1 environment
- Already using Terraform Cloud/Enterprise with workspace management
- Team isn't yet comfortable with Terraform — learn plain Terraform first
Wrapping up
In this post, I introduced Terragrunt and walked you through a complete lab you can run locally. I hope it's clear now what value Terragrunt brings when managing infrastructure at scale following the DRY principle.
If this post was useful, feel free to follow and upvote to support me. Thanks! ❤️
If you're running into technical challenges, need help with systems, DevOps tools, or career direction, I'm confident I can help. Get in touch at hoangviet.io.vn — I'd love to chat and collaborate with you.