Terragrunt: keeping 30 environments DRY

Stop copy-pasting backend and provider blocks across environments — generate them and keep inputs in one place.

Nov 19, 2024·Updated ·5 min readAdvanced·By SecOpsLog · documentation-verified

Thirty environments built from the same modules should differ in thirty small files, one per environment, each saying which module version and which inputs. Without a wrapper they differ in thirty copies of a backend "s3" block, thirty provider blocks, and thirty chances to paste a staging state key into a production directory. Terragrunt is the wrapper: it generates the shared configuration at run time, calls Terraform (or OpenTofu) in the right directory, and leaves the modules themselves as ordinary Terraform. It solves repetition. It does not solve a module whose interface needs twenty per-environment overrides, and it makes that problem harder to see.

Two repositories, one job each

live/ (the environments; modules live elsewhere, tagged)
live/
root.hcl # remote_state + generate: written once
prod/
vpc/terragrunt.hcl # module version + inputs, nothing else
eks/terragrunt.hcl
staging/
vpc/terragrunt.hcl
eks/terragrunt.hcl

Modules live in their own repository and are consumed by git tag; the live repository holds one directory per account, region and stack (Terragrunt calls each of these a unit, and a directory of units a stack). A reviewer opening prod/vpc/terragrunt.hcl should see two things: the module version, and the inputs that differ from staging. Generated files (backend.tf, provider.tf) and .terragrunt-cache/ stay out of version control; CI and local runs regenerate them, which is the point.

Generate the backend and providers once

live/root.hcl
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-tf-state"
key = "${path_relative_to_include()}/terraform.tfstate" # prod/vpc/terraform.tfstate
region = "eu-west-1"
encrypt = true
use_lockfile = true # S3 native lock; no DynamoDB table
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "eu-west-1"
default_tags {
tags = { ManagedBy = "terragrunt", Unit = "${path_relative_to_include()}" }
}
}
EOF
}

path_relative_to_include() is what makes the state key unique per unit without anyone typing it: prod/vpc and staging/vpc get different keys by virtue of being different directories. use_lockfile = true goes straight into the generated backend, so the units lock through the S3 lockfile that current Terraform and OpenTofu support and no DynamoDB table is created; a live repository that still carries dynamodb_table migrates by running both for a window, moving every runner to a lockfile-capable version, then removing the table setting. For the S3 backend Terragrunt also reconciles the bucket with the remote_state block (versioning, for example), which is convenient on day one and a permission to be aware of on day two hundred.

live/prod/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::https://git.acme.dev/modules/vpc.git?ref=v1.4.2" # one pin to bump per environment
}
inputs = {
name = "prod"
cidr = "10.20.0.0/16"
}

Dependencies by output, not by reading state

When the EKS unit needs the VPC id, a dependency block reads the VPC unit's outputs, which keeps the coupling at the module interface. Reaching into another unit's state file directly couples the two forever and breaks the moment the state key or the backend changes. Terragrunt orders dependent units when running across a stack, and the graph is worth keeping shallow: a chain of five dependencies turns a plan into five sequential inits and makes a failure in the middle hard to attribute.

live/prod/eks/terragrunt.hcl (excerpt)
dependency "vpc" {
config_path = "../vpc"
mock_outputs = { vpc_id = "vpc-00000000" } # lets plan run before the VPC exists
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
cluster_name = "prod"
}
bash — one unit, then only the units a change touched
cd live/prod/vpc && terragrunt plan
Downloading Terraform configurations from git::https://git.acme.dev/modules/vpc.git?ref=v1.4.2
Plan: 12 to add, 0 to change, 0 to destroy.
cd live && terragrunt run --all --filter-affected -- plan
the units whose files changed between the default branch and HEAD, plus what depends on them
terragrunt run --all --filter "prod/**" -- plan
everything under prod, in dependency order; run-all is the older spelling of the same command

run --all is the command that turns thirty directories into one pipeline step, and --filter-affected is what keeps that step proportional to the merge request rather than to the estate. Bumping a module from v1.4.2 to v1.5.0 is then a change to one line per environment, planned only where it applies, which is the DRY promise delivered without a wrapper generating the wrapper.

Twenty per-environment inputs point at the module
Terragrunt removes the boilerplate around a module. It cannot make a module with forty optional flags readable, and it makes the divergence between staging and prod easier to hide in an inputs block. When two units of the same module disagree in twenty places, the interface is wrong; fix the module and let the units shrink back to a version and a handful of values.
Where the wrapper helps and where it hurts
Helps
Many environments from the same modules
Backend and provider boilerplate generated, not pasted
Module versions pinned per unit, bumped in one line
A live/ and modules/ split that reviewers can read
Hurts
One account, three stacks: plain Terraform is simpler
A team still learning Terraform itself
Generated files committed by accident
Deep or circular dependency graphs

The per-directory layout that Terragrunt makes cheap is the one workspaces are the wrong tool for, and the lockfile in the generated backend is the same mechanism remote state describes, including the stale-lock decision. Pinning Terragrunt and Terraform versions in CI belongs next to the module pins: the wrapper is a dependency too.

Go deeper in a courseTerragruntThe live-repository pattern end to end: units, stacks, dependencies and CI.View course

Related posts

Quick reference