DevOps Peach

Advanced Terraform · OpenTofu, Terragrunt and Security · Lesson 3 of 4

Terragrunt: DRY Configuration Across Stacks

Terragrunt wraps Terraform/OpenTofu: one root file generates backends and providers for every unit, include shares settings, dependency blocks pass outputs between units, and run --all applies them in order.

Practice interactively →

With twenty environment folders, the same backend block, provider block and module call are copied twenty times. Terragrunt is a thin wrapper that generates that boilerplate and runs units in dependency order. It drives tofu or terraform underneath.

flowchart TB
  ROOT["root.hcl: remote_state + provider generate"] --> NET["live/dev/network/terragrunt.hcl"]
  ROOT --> APP["live/dev/app/terragrunt.hcl"]
  NET -->|"dependency outputs"| APP
  RA["terragrunt run --all apply"] --> NET
  RA --> APP
  %% step ROOT: The root file defines the backend and provider once; the state key comes from each unit's folder path.
  %% step NET: A unit is a folder with a terragrunt.hcl that points at a module and sets its inputs.
  %% step APP: The app unit reads the network unit's outputs through a dependency block.
  %% step RA: run --all walks every unit below the current folder in dependency order.

Layout

live/
  root.hcl
  dev/
    network/terragrunt.hcl
    app/terragrunt.hcl
  prod/
    network/terragrunt.hcl
    app/terragrunt.hcl
# live/root.hcl
remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite_terragrunt" }
  config = {
    bucket       = "shop-tfstate"
    key          = "${path_relative_to_include()}/terraform.tfstate"   # dev/app/terraform.tfstate
    region       = "us-east-1"
    use_lockfile = true
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = "provider \"docker\" {}"
}
# live/dev/app/terragrunt.hcl
include "root" {
  path = find_in_parent_folders("root.hcl")
}

terraform {
  source = "git::https://github.com/example-org/tf-modules.git//app?ref=v1.6.0"
}

dependency "network" {
  config_path = "../network"
  mock_outputs = { network_name = "mock-net" }     # lets plan work before network exists
}

inputs = {
  env          = "dev"
  network_name = dependency.network.outputs.network_name
}

Note the backend key: because each unit has its own folder, every unit automatically gets its own state.

Running it

Command Effect
terragrunt plan Plan one unit (in its folder)
terragrunt run --all plan Plan every unit below the current folder, in order
terragrunt run --all apply Apply all, network before app
terragrunt dag graph Show the dependency graph

Older Terragrunt versions used terragrunt run-all plan; current versions use run --all.

When it is worth it

Use Terragrunt when you have many environments or accounts with the same building blocks. For two or three environments, plain folders and a module are simpler. Newer Terragrunt versions also offer stacks (terragrunt.stack.hcl) to generate whole sets of units.

Traps: run --all apply across prod applies many states in one command, so pipelines usually run it per environment with approval; and mock_outputs used during apply would wire fake values into real resources, so restrict them to plan/validate with mock_outputs_allowed_terraform_commands.

Practice interactively →