Learn Terraform - Advanced Architecture with Terragrunt
Episode 12 of 21

Learn Terraform - Advanced Architecture with Terragrunt

Get to know Terragrunt, the thin wrapper by Gruntwork that removes backend and provider duplication in multi-environment architectures, along with the include, dependency, inputs, and run-all features to keep Terraform code DRY.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After discussing in episode 11 how to keep code quality through terraform fmt, tflint, Checkov, up to automated testing with Terratest — in this episode we'll discuss one architectural layer that makes all those practices feel lighter: Terragrunt.

In episode 10, you were introduced to the directory-based pattern to separate environments (dev/, staging/, prod/). That pattern is architecturally correct, but there's one problem that comes with it: duplication. Every environment folder, even every component inside it, must repeat the backend "s3" { ... } block, the provider "aws" { ... } block, and the same configuration over and over. Imagine your team has 5 environments and 6 infrastructure components in each environment — that's 30 copies of identical backend configurations. When there's a change as small as one region, you have to modify 30 files. This is where Terragrunt comes in as the answer.

Why is this topic important in the real working world? Because once a team grows past one environment, the biggest problem is no longer writing Terraform code, but managing repetition without losing flexibility. Terragrunt is a tool used by thousands of companies — including many unicorn startups — to make infrastructure scale consistently. You'll encounter it very often in DevOps/SRE jobs, so understanding its architecture now will pay off handsomely later.

Main Discussion

The DRY Problem in Multi-Environment Architectures

Before diving into Terragrunt, let's clarify the problem we want to solve. Remember the backend configuration from episode 5:

dev/vpc/backend.tf
terraform {
  backend "s3" {
    bucket         = "myapp-tfstate-bucket"
    key            = "dev/vpc/terraform.tfstate"
    region         = "ap-southeast-1"
    encrypt        = true
    dynamodb_table = "myapp-tfstate-lock"
  }
}

Now copy the same block to dev/eks/backend.tf, dev/rds/backend.tf, prod/vpc/backend.tf, and so on. Not to mention the provider block usually written uniformly across all folders:

dev/vpc/provider.tf
provider "aws" {
  region = "ap-southeast-1"
  default_tags {
    tags = {
      Environment = "dev"
      ManagedBy   = "terraform"
    }
  }
}

The problem isn't just "the code isn't pretty". It's a maintenance problem: every backend or provider configuration change must be manually distributed across all folders, and it's very easy for one folder to be missed. That missed folder then uses old configuration that may be wrong or insecure. This is what's called a DRY violation — we repeat the same thing in many places, even though the golden rule of engineering is Don't Repeat Yourself.

Note

Modules (episode 9) already solved the resource duplication problem, but they don't solve the duplication of configuration around the resources — namely the same backend, provider, and variables repeated in every environment folder. Terragrunt fills this gap, it doesn't replace modules.

Getting to Know Terragrunt

Terragrunt is a thin wrapper — a thin layer over the Terraform CLI — created by Gruntwork (the company founded by Yevgeniy Brikman, author of the book Terraform: Up & Running). Terragrunt doesn't rewrite Terraform; it reads its own configuration file named terragrunt.hcl, then calls terraform behind the scenes according to the instructions.

Think of Terragrunt like a project manager organizing workers. Each worker (Terraform) is very smart at building one building (one infrastructure component), but they need coordination: where's the blueprint from, where's the same material, what's the work order between buildings. Terragrunt coordinates all of that, so the workers don't need to be re-explained the same thing every time.

Terragrunt's philosophy is summarized in three pillars:

  1. DRY — one backend/provider configuration written once, inherited by all environments.
  2. Dependency graph — running interdependent modules in the correct order automatically.
  3. Multi-environment orchestration — executing plan/apply for many folders at once with a single run-all command.

Terragrunt Main Features: Key Keywords

To understand Terragrunt, you must master the following five keywords. These are the everyday language of Terragrunt users:

KeywordFunctionExample Usage
terraform.sourceThe location of the Terraform module to executesource = "../modules//vpc"
includeInherits configuration from the parent file (root terragrunt.hcl)include "root" { path = find_in_parent_folders() }
remote_stateCentralized backend state configurationremote_state { backend = "s3" ... }
generateAutomatically creates .tf files (e.g. provider.tf)generate "provider" { path = "provider.tf" ... }
dependencyReads outputs from another module + builds a dependency graphdependency.vpc.outputs.vpc_id
inputsPasses variables to the Terraform moduleinputs = { environment = "prod" }
run-allRuns commands across all units in the graphterragrunt run-all plan

Important

Notice the two dependency writing styles: dependency (block) is used at the unit level to read another module's outputs, while terraform_remote_state (data source) is Terraform's own old way. Terragrunt chooses dependency because it's more explicit, also builds the graph, and has mock_outputs so plan still works even if the dependency module has never been applied.

Directory Structure with Terragrunt

The common Terragrunt repository pattern is split into two separate repos: modules (reusable module code) and live (per-environment configuration that calls modules). Here's a recommended structure example:

infrastructure-live repository directory structure
infrastructure-live/
├── terragrunt.hcl                      # Root: backend + provider + global variables
├── modules/                            # (usually a separate repo: infra-modules)
   ├── vpc/
   ├── eks/
   └── rds/
├── dev/
   ├── vpc/terragrunt.hcl
   ├── eks/terragrunt.hcl
   └── rds/terragrunt.hcl
└── prod/
    ├── vpc/terragrunt.hcl
    ├── eks/terragrunt.hcl
    └── rds/terragrunt.hcl

Notice one thing: there are no more .tf files in the environment folders. Each folder only contains a single terragrunt.hcl file. The backend.tf and provider.tf files are generated automatically by Terragrunt at execution time. This is the key to DRY: you don't write them manually, Terragrunt writes them for you.

Root terragrunt.hcl: Centralized Backend & Provider

The core of this entire architecture is in the root file. This file is inherited (via include) by all folders beneath it. Let's dissect its contents:

terragrunt.hcl (root)
locals {
  environment = basename(get_terragrunt_dir())
}
 
generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<-EOF
    provider "aws" {
      region = "${local.region}"
      default_tags {
        tags = {
          Environment = "${local.environment}"
          ManagedBy   = "terragrunt"
        }
      }
    }
  EOF
}
 
remote_state {
  backend = "s3"
  config = {
    bucket         = "myapp-tfstate-bucket"
    key            = "${local.environment}/${path_relative_to_include()}/terraform.tfstate"
    region         = "ap-southeast-1"
    encrypt        = true
    dynamodb_table = "myapp-tfstate-lock"
  }
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
}
 
inputs = {
  aws_region = "ap-southeast-1"
  environment = local.environment
}

Explanation of the important parts:

  • locals.environment — computed automatically from the folder name (dev, prod). When this file is inherited into prod/vpc/, its value becomes prod; in dev/eks/ it becomes dev. One value source, many uses.
  • generate "provider" — tells Terragrunt to create the provider.tf file in each unit folder every time it runs. if_exists = "overwrite_terragrunt" ensures Terragrunt-created files are overwritten without asking. Notice we don't need to write a manual provider.tf file anywhere.
  • remote_state — the single place where the backend is defined. The state key is composed automatically from the environment + relative path, so each unit folder has its own isolated state, exactly the directory-based philosophy from episode 10.
  • inputs — values passed as variables to the Terraform module, replacing the terraform.tfvars files that were previously duplicated.

Tip

path_relative_to_include() is a Terragrunt function that computes the relative path of the unit folder to the root folder. The combination of basename(get_terragrunt_dir()) and path_relative_to_include() is the two most commonly used functions for building consistent, unique state keys.

Child terragrunt.hcl: include + inputs

Each unit folder under dev/ or prod/ only needs three things: inherit the root, point to the module, and send variables. Example for the VPC in the prod environment:

prod/vpc/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}
 
terraform {
  source = "${get_parent_terragrunt_dir()}//modules/vpc"
}
 
inputs = {
  environment = "prod"
  vpc_name    = "vpc-main"
  cidr_block  = "10.0.0.0/16"
  enable_nat  = true
}
  • include "root"find_in_parent_folders() looks upward for the nearest terragrunt.hcl file and inherits all of its configuration (provider, backend, inputs). All duplication is removed in one step.
  • terraform.source — points to the module location. The // symbol in ...//modules/vpc indicates that modules/ is the root module directory and vpc is its submodule — the standard Terraform registry syntax that remains valid in Terragrunt.
  • inputs — environment-specific values. You're free to add the same values or merge with global values from the root. This is the flexibility we've been looking for: global at the root, specific at the unit.

Note

When a unit defines inputs that are the same as the root, the unit's values win (they override). If you want to change the merge strategy so the root always wins, you can set merge_strategy = "deep" or "no_merge" on the include block.

Connecting Modules with dependency

Infrastructure rarely stands alone: an EKS cluster needs a VPC, an RDS database needs to be in a subnet created by the VPC. Terragrunt connects these modules through the dependency block. Example prod/eks/terragrunt.hcl consuming outputs from prod/vpc:

prod/eks/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}
 
terraform {
  source = "${get_parent_terragrunt_dir()}//modules/eks"
}
 
dependency "vpc" {
  config_path = "../vpc"
 
  mock_outputs = {
    vpc_id              = "vpc-00000000"
    private_subnet_ids  = ["subnet-00000000"]
    public_subnet_ids   = ["subnet-11111111"]
  }
}
 
inputs = {
  vpc_id             = dependency.vpc.outputs.vpc_id
  private_subnet_ids = dependency.vpc.outputs.private_subnet_ids
  public_subnet_ids  = dependency.vpc.outputs.public_subnet_ids
}

What happens behind the scenes:

  1. config_path lets Terragrunt know that eks depends on vpc.
  2. When terragrunt plan is run on eks, Terragrunt automatically reads the vpc state (performing a quick initialization in that folder) then loads its outputs values.
  3. Those values are passed as inputs to the EKS module — exactly like throwing data between components without hardcoding.

mock_outputs is a safety net: its values are used temporarily when the vpc state doesn't exist yet (for example on the first run in a new repo), so plan doesn't error just because of ordering. In production, the real values from state replace them.

Warning

dependency is read-only: Terragrunt reads another module's outputs, but never automatically applies that module first. If vpc hasn't been applied, the output read is only mock_outputs. Get into the habit of running terragrunt run-all apply from the root so the entire graph is applied in the correct dependency order, not terragrunt apply per folder at random.

Running Terragrunt: Everyday Commands

Terragrunt can be run per unit or for the whole graph at once. Here are the most commonly used commands:

terragrunt plan

run-all is one of the most valuable features. It reads the entire dependency graph from the dependency blocks, then executes those units in parallel — but makes sure units that are dependencies finish first. Imagine 18 infrastructure units: a single run-all plan command is enough to see the change plan across the entire environment, without logging into each folder one by one.

Example terragrunt run-all apply output (truncated)
$ terragrunt run-all apply --terragrunt-non-interactive
 
Initializing the backend...
 
Successfully configured the backend "s3"!
Initializing provider plugins...
- Finding latest version of hashicorp/aws...
 
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
 
Outputs:
 
vpc_id = "vpc-0a1b2c3d4e5f6a7b8"
 
GROUP 5
- /infrastructure-live/prod/vpc
- /infrastructure-live/prod/eks
- /infrastructure-live/prod/rds
 
GROUP 4
...

Notice the output groups units by graph execution order (GROUP). Units that don't depend on each other run in parallel in the same group, and higher groups wait for lower groups to finish.

Terragrunt vs Pure Terraform Pattern

Here's a brief comparison to clarify Terragrunt's position in your architecture:

AspectPure Terraform (Directory-based)Terragrunt
Backend configurationDuplicated in each folderWritten once at the root, inherited
Provider configurationDuplicated in each folderGenerated via generate
Reading other modules' outputsManual terraform_remote_statedependency + mock_outputs
Multi-folder executionManual shell scriptsrun-all automatic along the graph
Per-env variable valuesSeparate .tfvars filesCentralized inputs + override
Initial complexityLowModerate (need to understand keywords)
Suitable forSmall teams, 1–2 environmentsLarge teams, many envs & components

Common Terragrunt Pitfalls

Here are the traps most often encountered in the field:

MistakeSymptomSolution
Forgetting if_exists on generateTerragrunt stops & asks for confirmation in CIUse overwrite_terragrunt
Folder without includeBackend/provider not formed → init errorMake sure every unit inherits the root
State key not unique between unitsTwo units share the same state → overwrite each otherCombine basename(get_terragrunt_dir()) + path_relative_to_include()
Applying per folder at randomWrong order → EKS fails because VPC doesn't exist yetUse run-all apply
Removing mock_outputsPlan errors in a new repo/clean CI stateAlways keep mock_outputs
Committing generate output files to GitConflicts and overwrite confusionAdd provider.tf/backend.tf patterns to .gitignore
Putting Terragrunt and modules in one repoRepo size balloons, tight couplingSeparate infra-live and infra-modules repos

Caution

Never commit generated files (e.g. the provider.tf, backend.tf created by Terragrunt in unit folders) to Git. Those files are artifacts that Terragrunt will overwrite; committing them only dirties the repo and causes conflicts when CI runs. Add them to .gitignore and let Terragrunt create them at runtime.

Conclusion

In this episode 12 we discussed how Terragrunt solves the configuration duplication problem that regular modules can't solve. We learned that the main problem in multi-environment architecture isn't writing code, but managing backend and provider repetition. With include, one root file becomes the source of truth; with generate, .tf files are composed automatically; with dependency, modules exchange outputs; and with run-all, the entire environment executes along the dependency graph with just one command.

Key takeaways to bring home:

  • Terragrunt is a thin wrapper, not a Terraform replacement — it only removes repetition around the code.
  • Three mandatory keywords: include (inheritance), inputs (variables), and dependency (relationships between modules).
  • Backend and provider are written just once at the root, then generated automatically per unit.
  • run-all turns the manual operation of 18 folders into one command.
  • Generated results are runtime artifacts — never commit them.

In the next episode 13, we'll bring this entire architecture to the next level with the topic CI/CD Pipeline Automation (GitHub Actions / GitLab CI) — building a pipeline that automatically runs terraform fmt, tflint, security scans, and plan, with an approval gate before apply, plus passwordless cloud login via OIDC. Stay excited!

Learn Terraform - Advanced Architecture with Terragrunt | Learn Terraform