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.

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.
Before diving into Terragrunt, let's clarify the problem we want to solve. Remember the backend configuration from episode 5:
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:
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.
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:
run-all command.To understand Terragrunt, you must master the following five keywords. These are the everyday language of Terragrunt users:
| Keyword | Function | Example Usage |
|---|---|---|
terraform.source | The location of the Terraform module to execute | source = "../modules//vpc" |
include | Inherits configuration from the parent file (root terragrunt.hcl) | include "root" { path = find_in_parent_folders() } |
remote_state | Centralized backend state configuration | remote_state { backend = "s3" ... } |
generate | Automatically creates .tf files (e.g. provider.tf) | generate "provider" { path = "provider.tf" ... } |
dependency | Reads outputs from another module + builds a dependency graph | dependency.vpc.outputs.vpc_id |
inputs | Passes variables to the Terraform module | inputs = { environment = "prod" } |
run-all | Runs commands across all units in the graph | terragrunt 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.
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/
├── 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.hclNotice 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.
terragrunt.hcl: Centralized Backend & ProviderThe 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:
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.
terragrunt.hcl: include + inputsEach 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:
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.
dependencyInfrastructure 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:
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:
config_path lets Terragrunt know that eks depends on vpc.terragrunt plan is run on eks, Terragrunt automatically reads the vpc state (performing a quick initialization in that folder) then loads its outputs values.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.
Terragrunt can be run per unit or for the whole graph at once. Here are the most commonly used commands:
terragrunt planrun-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.
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.
Here's a brief comparison to clarify Terragrunt's position in your architecture:
| Aspect | Pure Terraform (Directory-based) | Terragrunt |
|---|---|---|
| Backend configuration | Duplicated in each folder | Written once at the root, inherited |
| Provider configuration | Duplicated in each folder | Generated via generate |
| Reading other modules' outputs | Manual terraform_remote_state | dependency + mock_outputs |
| Multi-folder execution | Manual shell scripts | run-all automatic along the graph |
| Per-env variable values | Separate .tfvars files | Centralized inputs + override |
| Initial complexity | Low | Moderate (need to understand keywords) |
| Suitable for | Small teams, 1–2 environments | Large teams, many envs & components |
Here are the traps most often encountered in the field:
| Mistake | Symptom | Solution |
|---|---|---|
Forgetting if_exists on generate | Terragrunt stops & asks for confirmation in CI | Use overwrite_terragrunt |
Folder without include | Backend/provider not formed → init error | Make sure every unit inherits the root |
State key not unique between units | Two units share the same state → overwrite each other | Combine basename(get_terragrunt_dir()) + path_relative_to_include() |
| Applying per folder at random | Wrong order → EKS fails because VPC doesn't exist yet | Use run-all apply |
Removing mock_outputs | Plan errors in a new repo/clean CI state | Always keep mock_outputs |
Committing generate output files to Git | Conflicts and overwrite confusion | Add provider.tf/backend.tf patterns to .gitignore |
| Putting Terragrunt and modules in one repo | Repo size balloons, tight coupling | Separate 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.
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:
include (inheritance), inputs (variables), and dependency (relationships between modules).run-all turns the manual operation of 18 folders into one command.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!