Skip to content
Infrastructure as Code iac environments 4 min read

Terragrunt

Terragrunt is a thin wrapper around Terraform (and OpenTofu) that solves two problems plain Terraform leaves to copy-paste: keeping backend and provider configuration DRY across many environments, and orchestrating the order in which interdependent stacks are applied. Instead of duplicating backend blocks and shared variables in every directory, you describe them once and inherit them everywhere. As soon as you have more than a couple of environments or a handful of stacks per environment, the boilerplate and the manual ordering become a real maintenance burden — Terragrunt is the tool most teams reach for to keep that under control.

The problem Terragrunt solves

A typical multi-environment layout gives every environment its own directory with its own state file. With vanilla Terraform, each of those directories needs a near-identical backend block (differing only by the state key), the same provider configuration, and the same handful of account-wide inputs. Change your S3 bucket name or add a default_tags block and you are editing a dozen files. Terragrunt lets you generate that repeated config from a single source of truth.

It also addresses ordering. Your networking stack must apply before your database stack, which must apply before your application stack. Terraform has no concept of “apply these root modules in dependency order” — Terragrunt does, through dependency blocks and run-all.

The terragrunt.hcl file

Configuration lives in terragrunt.hcl files. A root file at the top of your repo holds shared logic; each leaf directory has a small file that points back to the root and supplies only what is unique to it.

# live/terragrunt.hcl  (the root config)
remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket         = "acme-tf-state"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "acme-tf-locks"
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      ManagedBy = "terragrunt"
    }
  }
}
EOF
}

The key uses path_relative_to_include(), so every stack automatically gets a unique state path derived from its location on disk. The generate blocks write real .tf files at run time, which means the Terraform code itself stays free of backend and provider boilerplate.

A leaf stack then only needs to include the root and pick a module:

# live/prod/vpc/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "${get_repo_root()}/modules//vpc"
}

inputs = {
  cidr_block = "10.20.0.0/16"
  azs        = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

Dependencies between stacks

dependency blocks let one stack consume another’s outputs without hard-coding values or wiring up terraform_remote_state by hand. Terragrunt reads the dependency’s outputs and exposes them, and it also uses these blocks to compute apply order.

# live/prod/app/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

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

  mock_outputs = {
    vpc_id     = "vpc-00000000"
    subnet_ids = ["subnet-0000", "subnet-1111"]
  }
}

terraform {
  source = "${get_repo_root()}/modules//app"
}

inputs = {
  vpc_id     = dependency.vpc.outputs.vpc_id
  subnet_ids = dependency.vpc.outputs.subnet_ids
}

The mock_outputs are used during plan/validate before the dependency has been applied, so planning doesn’t fail on a not-yet-existing upstream stack.

Running across many stacks with run-all

run-all walks the directory tree, builds a dependency graph from the dependency blocks, and runs a command against every stack in the correct order (in parallel where it can).

cd live/prod
terragrunt run-all plan
terragrunt run-all apply

Output:

Group 1
- Module live/prod/vpc

Group 2
- Module live/prod/app

aws_vpc.main: Creating...
aws_vpc.main: Creation complete after 2s [id=vpc-0a1b2c3d]
...
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Use terragrunt run-all destroy with care — it tears down every stack under the current directory. Scope it to a single environment directory and always review the plan first.

OpenTofu users can point Terragrunt at the tofu binary with TERRAGRUNT_TFPATH=tofu (or terraform_binary = "tofu" in config); all of the above behaves identically.

When Terragrunt helps vs plain Terraform

SituationPlain TerraformTerragrunt
One or two environmentsFine — native workspaces or copiesOverkill
Many environments, repeated backend configLots of duplicationDRY via root terragrunt.hcl
Cross-stack outputsManual terraform_remote_statedependency blocks
Applying stacks in orderManual, one dir at a timerun-all with a dependency graph
Generated provider/backend filesCopy-paste per dirgenerate blocks

If you only have a single root module and one or two environments, native Terraform workspaces or a simple directory split are simpler. Terragrunt earns its keep once duplication and ordering start costing you real time.

Best Practices

  • Keep one root terragrunt.hcl as the single source of truth for backend and provider config, and let leaves inherit via find_in_parent_folders().
  • Derive state keys from path_relative_to_include() so every stack gets isolated state automatically.
  • Always define mock_outputs on dependency blocks so plan works before upstream stacks exist.
  • Keep root modules small and single-purpose (vpc, database, app) so run-all ordering stays meaningful and blast radius stays small.
  • Pin the Terragrunt and Terraform/OpenTofu versions in CI to keep generated files and behavior reproducible.
  • Scope run-all destroy to a specific environment directory and review every plan before applying.
Last updated June 14, 2026
Was this helpful?