← Back to Blog

What is Terragrunt? Managing Terraform Across Multiple Environments/Tenants

If you've worked with Terraform for a while, you know this feeling: everything runs great with a single environment. But as the project grows and you need to manage dev, staging, and prod with dozens of modules, code duplication starts creeping in everywhere.

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, subnets
  • database — manages RDS, database instances
  • app — 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 overview diagram

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

AspectPlain TerraformTerraform + Terragrunt
Backend configDuplicated in every moduleDefined once at the root
Provider configDuplicated in every moduleAuto-generated for every module
Dependencies between modulesManual, via terraform_remote_statedependency block, automatic
Applying multiple modulesMust run each module in orderterragrunt run --all apply
Environment variablesCopy/paste with small changesRead 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 a root.hcl file in a parent directory
  • read_terragrunt_config() — read any HCL file into locals
  • get_terragrunt_dir() — get the absolute path of the directory containing the current terragrunt.hcl
  • path_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:

FunctionMeaning
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.hcl files for prod/network, prod/database, prod/app have the exact same structure and content as dev/. No difference at all! Why? Because the difference between the two environments is entirely pushed into env.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:

ProblemTerragrunt's solution
backend.tf duplicated across 6 modulesA remote_state block at the root, auto-generated
provider.tf duplicated across 6 modulesA generate "provider" block at the root
Must apply manually in orderrun --all apply with an automatic dependency graph
Copying env config when adding an environmentenv.hcl holds the variables, the module's terragrunt.hcl is shared
Pulling another module's outputA 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.

Have thoughts on this?

I'd love to hear your perspective. Send me a message.

Get in touch