Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Terraform By James Joyner IV · · 9 min read

Keeping Terraform DRY With Terragrunt Without the Magic

Terragrunt promises DRY Terraform across dozens of environments, but it's easy to bury your config in indirection. Here's how to adopt it deliberately and keep it debuggable.

  • #terraform
  • #terragrunt
  • #dry
  • #environments
  • #iac
  • #backends

Once you’re running Terraform across a dozen accounts and three regions, the copy-paste starts to hurt. Every environment directory has its own backend.tf with a bucket name that differs by one word, its own provider block, its own boilerplate. Change your remote-state key convention and you’re editing forty files. This is the pain Terragrunt was built to remove.

I like Terragrunt. I also watched a team drown in it — a config so layered with include and read_terragrunt_config that nobody could answer “what backend does prod actually use?” without running it. Here’s how to get the DRY benefits without the where-does-this-value-come-from misery.

What Terragrunt actually is

Terragrunt is a thin wrapper around the Terraform CLI. It doesn’t replace Terraform; it generates the boilerplate Terraform hates to repeat — primarily backend configuration and provider blocks — and orchestrates runs across many modules. You write your modules in plain HCL, and Terragrunt supplies the per-environment glue.

The unit of work is a terragrunt.hcl file in each leaf directory. It points at a module and passes inputs:

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

inputs = {
  instance_count = 6
  instance_type  = "m5.2xlarge"
}

Run terragrunt apply in that directory and it pulls the module, wires up the backend, and runs Terraform for you.

The one feature that earns its keep: generated backends

The single most valuable thing Terragrunt does is kill backend duplication. You define the convention once, at the root, and every child inherits it with a state key derived from its path.

Root terragrunt.hcl:

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

That path_relative_to_include() is the trick: each environment’s state key is its directory path, so prod/us-east-1/app and staging/us-east-1/app get distinct, predictable keys with zero per-directory config. Define it once; never touch a backend.tf again.

A child opts in with one line:

include "root" {
  path = find_in_parent_folders()
}

Keeping inputs DRY without burying them

The second big win is shared inputs. Common values — region, tags, account IDs — live in a parent config, and environments override only what differs. The clean way to layer this is environment-level config that children include:

live/
  terragrunt.hcl            # root: backend + provider generation
  prod/
    env.hcl                 # account_id, default tags for prod
    us-east-1/
      app/terragrunt.hcl    # includes root + reads env.hcl, sets app inputs

A leaf reads the environment file and merges:

include "root" {
  path = find_in_parent_folders()
}

locals {
  env = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}

inputs = {
  account_id = local.env.locals.account_id
  tags       = local.env.locals.default_tags
  instance_count = 6
}

This is where the magic-versus-clarity line lives. Two levels of inclusion — root for backend, env for account-level values — is readable. Five levels of read_terragrunt_config chasing values through the tree is not. My rule: no more than two layers of indirection between a value and the place you read it. If you can’t answer “where does account_id come from?” by opening two files, you’ve over-engineered.

Dependencies between modules

Terragrunt’s dependency block lets one module consume another’s outputs without terraform_remote_state plumbing:

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

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

This is genuinely nice — it makes the dependency graph explicit and lets terragrunt run-all apply order things correctly across an entire environment. Use mock_outputs in the dependency block so plan works before the dependency exists, and you’ve got a clean way to plan a whole environment in one command.

Where teams get burned

A few failure modes I’ve watched up close:

  • Over-nesting includes. Each include is a place a value can be set, overridden, or shadowed. Keep the layers shallow and named.
  • Pinning the wrong thing. Pin your module source to a tag (?ref=v1.4.0), not a branch. A floating main means terragrunt apply can pull a different module than the one you reviewed.
  • run-all on prod. terragrunt run-all apply across prod is a loaded gun — it applies many modules at once. Run it for plan freely; for apply in prod, go module by module.
  • Treating it as required. If you have three environments and one region, you may not need Terragrunt at all. Plain directory-per-environment Terraform is simpler. Terragrunt pays off at scale — many accounts, many regions, many repeated stacks.

Should you adopt it?

Reach for Terragrunt when the boilerplate is your pain: you’re maintaining the same backend and provider blocks across a sprawling matrix of environments and regions, and the duplication is where your bugs live. Skip it when your environment count is small enough that explicit, plain-Terraform directories are still readable — the indirection costs more than it saves below a certain scale.

When you do adopt it, review the generated config in plans the same way you’d review any change. An extra reviewer catching a backend key that resolved wrong before it creates a duplicate state file saves a bad afternoon — that’s the kind of thing our AI code review tooling helps with. For more on structuring Terraform at scale, see our Terraform guides.

Examples are illustrative. Always verify generated backends and dependency outputs against a plan before applying in production.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.