Terragrunt Explained — When You Actually Need It

Terragrunt shows up in a lot of infrastructure repos, and it shows up in a lot of arguments too. Some teams swear it saved them from copy-paste hell. Others rip it out after a year and wonder why they added a whole extra tool. Both camps are usually right, because Terragrunt earns its keep in specific conditions and becomes dead weight outside them.

This post walks through what Terragrunt is, the concrete problems it solves, what it costs you, and how to decide whether your setup is one where it pays off.

What Terragrunt actually is #

Terragrunt is a thin wrapper around Terraform (and now OpenTofu). It does not replace Terraform. Under the hood it still calls the same binary, generates the same plan, and manages the same state. What it adds is a layer that handles the boilerplate Terraform makes you repeat: backend configuration, provider blocks, variable wiring, and running commands across many modules at once.

If you have never hit that boilerplate as a real problem, that sentence probably sounds like a solution looking for a problem. That is the honest tension at the center of Terragrunt. It only makes sense once your repo is big enough that repetition genuinely hurts.

The problems it solves #

DRY backend config: In plain Terraform, every module that keeps remote state needs a backend block, and those blocks cannot use variables. So you end up hardcoding the same S3 bucket, region, and a slightly different key in dozens of places. Terragrunt generates that block for you from one shared definition, changing only the state key per module.

DRY provider config: The same story applies to provider blocks, required_providers, assume-role settings, and default tags. Terragrunt can generate a provider.tf into each module so you define the pattern once and inherit it everywhere.

Many environments and state files: When you run dev, staging, and prod as separate state files (which you should for blast-radius reasons), the input differences between them are usually small. Terragrunt lets you keep the module code in one place and feed each environment its own inputs, without duplicating the module per environment.

Dependencies between modules: Real infrastructure has ordering. The VPC has to exist before the database, the cluster before the app. Terragrunt models this with dependency blocks that pass one module's outputs into another's inputs and enforce apply order.

run-all across a stack: Once dependencies are declared, terragrunt run-all apply walks the whole graph and applies everything in the right order. For a platform team managing dozens of components, that single command is a big part of the appeal.

The terragrunt.hcl model #

Each leaf directory holds a small terragrunt.hcl that points at a module and supplies inputs. Shared settings live in a parent file that children inherit through include.

hcl.hcl

# live/prod/us-east-1/database/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}

terraform {
source = "git::git@github.com:acme/modules.git//rds?ref=v1.4.0"
}

dependency "vpc" {
config_path = "../vpc"
}

inputs = {
instance_class = "db.r6g.large"
subnet_ids = dependency.vpc.outputs.private_subnet_ids
environment = "prod"
}

The root file (found via find_in_parent_folders) holds the backend and provider generation that every module shares, so no leaf ever repeats it.

The folder layout is where the model clicks. You typically split immutable module code from the live configuration that instantiates it:

text.text

infra/
├── modules/ # reusable Terraform modules
│ ├── vpc/
│ ├── rds/
│ └── eks/
└── live/
├── terragrunt.hcl # root: backend + provider generation
├── dev/
│ └── us-east-1/
│ ├── vpc/terragrunt.hcl
│ ├── database/terragrunt.hcl
│ └── eks/terragrunt.hcl
└── prod/
└── us-east-1/
├── vpc/terragrunt.hcl
├── database/terragrunt.hcl
└── eks/terragrunt.hcl

Adding a new environment becomes a matter of copying a directory of small config files, not duplicating module logic.

The downsides #

Terragrunt is not free. The costs are real and worth naming.

Bold: It is another layer. You now debug across two tools. When something breaks, you have to work out whether the fault is in your Terraform, in the generated code, or in Terragrunt's orchestration. Generated backend.tf and provider.tf files are not in your source, which surprises people reading the repo for the first time.

Learning curve: HCL functions like find_in_parent_folders, read_terragrunt_config, and the dependency/mock_outputs dance take time to learn. New engineers who already know Terraform still have to learn a second mental model on top.

Overlap with native features: Terraform has closed some of the gap Terragrunt originally filled. Partial backend config via -backend-config files, provider default tags, and the newer stacks and test tooling all reduce the "you have no other option" argument. Terragrunt still does more, but the case is narrower than it was in 2019.

Alternatives #

Native workspaces: Good for a handful of near-identical environments off one config. They share a backend key prefix and get awkward once environments diverge, so treat them as a light-touch option rather than a scaling strategy.

Terramate: A newer orchestration layer that generates Terraform code and adds change detection, so it only runs stacks that actually changed. Worth a look if orchestration is your main pain rather than DRY config.

Plain modules plus a wrapper script: A well-factored module library plus a short Makefile or shell script covers a surprising number of teams. You lose run-all and dependency graphs, but you keep the stack simple.

For where these sit against each other, see our roundup of the best Infrastructure-as-Code tools. If you are also weighing engines, Terraform vs OpenTofu is the companion read.

Deciding #

Ask a few blunt questions:

How many state files do you manage? Under ten, the DRY payoff is small.

How many environments, and how different are they? Many near-identical environments is Terragrunt's sweet spot.

Do you have real cross-module dependencies you apply together? If yes, run-all is genuinely useful.

Who maintains this? A platform team can absorb the tool. A single app team often cannot justify it.

If most answers point at "small and simple," native Terraform will serve you better and read more plainly to the next person.

The call we'd make #

Reach for Terragrunt when you are running many environments across many components with a platform team that owns the tooling. That is exactly the situation it was built for, and it will save you from a repo full of near-duplicate backend blocks and manual apply ordering.

Skip it when you have one or two environments, a modest number of state files, and no dedicated platform owner. In that world the extra layer costs more attention than it returns, and plain Terraform modules with a thin script will keep your setup easier to reason about. Start without it, and adopt it the day repetition and apply-ordering stop being annoyances and start being incidents.