Skip to content

Terragrunt for DRY Terraform modules

IaC · Terraform
Studied

Problem

For three environments (dev, staging, prod) Terraform spawns three directories, each with a copy-pasted main.tf, backend.tf, providers.tf. Changing a provider takes three nearly identical PRs. Backend configs drift apart, and terraform init accidentally writes state into another environment. It all smells, but it still works.

Solution

Terragrunt is "a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale". For this case it gives two things:

  1. DRY configuration. The backend, providers and shared variables are described once in the root terragrunt.hcl. Child terragrunt.hcl files inherit them via include.
  2. Backend derived from the path. The S3/Azure Blob state key is generated from the path in the repository — mixing up environments becomes physically impossible.

Example

Layout:

infra/
├── terragrunt.hcl                 # root: remote state, providers
├── modules/
│   └── vpc/
│       ├── main.tf
│       └── variables.tf
└── environments/
    ├── dev/
    │   └── vpc/terragrunt.hcl
    ├── staging/
    │   └── vpc/terragrunt.hcl
    └── prod/
        └── vpc/terragrunt.hcl

The root terragrunt.hcl — defined once:

remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket         = "acme-tfstate"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "eu-central-1"
    encrypt        = true
    dynamodb_table = "acme-tfstate-lock"
  }
}

generate "providers" {
  path      = "providers.tf"
  if_exists = "overwrite_terragrunt"
  contents  = file("${get_terragrunt_dir()}/../../_shared/providers.tf")
}

environments/prod/vpc/terragrunt.hcl — all the environment-specific code:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../../modules/vpc"
}

inputs = {
  cidr_block = "10.10.0.0/16"
  env        = "prod"
}

Run it like regular Terraform, just through terragrunt:

cd environments/prod/vpc
terragrunt plan
terragrunt apply

# Run all environments at once
cd environments
terragrunt run-all plan

In recent Terragrunt releases run-all is replaced by terragrunt run --all plan; check the syntax against the version you use.

When to use

  • More than one environment, and configs are already drifting.
  • Several teams share one IaC repository.
  • You want "prod accidentally wrote state into the dev bucket" prevented by the tool, not by review.

Source

How to apply it yourself

  1. Extract shared code from the existing environments into modules/ and _shared/.
  2. Create a root terragrunt.hcl with remote_state and generate.
  3. Rewrite each environment as a small terragrunt.hcl with include + inputs.
  4. Manually delete the duplicated backend.tf/providers.tf from the environments.
  5. A terragrunt run-all plan with no resource changes = the migration went cleanly → status Studied.