BlogIaC

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·4 min readAdvanced·By the SecOpsLog team · command-tested

Thirty environments should not mean thirty nearly identical backend "s3" {} blocks and thirty chances to typo the state key. Terragrunt sits in front of Terraform: it generates the boring shared config, keeps environment inputs in one place, and still leaves real Terraform modules as the source of truth. Used well, it deletes copy-paste. Used poorly, it becomes a second language nobody can debug at 2am.

This note shows a layout that scales — root.hcl for shared generate blocks, per-env folders for inputs, and a module pin you can bump once. If you are still learning state and modules, start with Terraform and come back for the DRY layer.

How Terragrunt wraps a Terraform module

Terragrunt does not replace Terraform. It renders config, then shells out to terraform init/plan/apply with the right working directory.

1root.hclremote_state + generate…2env/terragrunt.hclinclude root + inputs3terraform {}source = git [email protected]4terragrunt planrenders + calls terraform5State in S3key includes env path6Applysame module, different inputs7Bump tagone pin upgrades all envs

A folder layout that stays navigable

Keep live infrastructure config out of the module repo. Modules live in git tags. Environments live in a separate live repo (or infra/live) with one folder per account/region/app. A reviewer should open prod/vpc/terragrunt.hcl and see exactly two things: which module version and which inputs differ from staging. Never commit .terragrunt-cache/ or generated backend.tf — let CI and local runs regenerate them.

live tree
live/
root.hcl
prod/
vpc/
terragrunt.hcl
eks/
terragrunt.hcl
staging/
vpc/
terragrunt.hcl

Generate backend and providers once

remote_state builds a unique state key from the path — ${path_relative_to_include()}/terraform.tfstate means prod and staging never share state. generate writes a providers.tf so humans never paste AWS provider blocks into every stack. Set if_exists = overwrite_terragrunt so regenerated files stay authoritative and nobody commits stale provider config.

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"
region = "eu-west-1"
encrypt = true
dynamodb_table = "acme-tf-locks"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "eu-west-1"
default_tags {
tags = { ManagedBy = "terragrunt" }
}
}
EOF
}
prod/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::https://git.example.com/modules/vpc.git?ref=v1.4.2"
}
inputs = {
name = "prod"
cidr = "10.20.0.0/16"
}
bash — plan one stacklive
cd live/prod/vpc && terragrunt plan
Downloading Terraform configurations from git::[email protected]
Terraform will perform the following actions:
Plan: 12 to add, 0 to change, 0 to destroy.

Dependencies without spaghetti

When EKS needs the VPC id, use dependency blocks and expose outputs from the VPC stack. Avoid reaching into another stack's state file by hand — that is how environments couple forever. Terragrunt runs dependencies in order during run-all apply, but keep the graph shallow; deep dependency chains make plans slow and failures opaque.

Use terragrunt.hcl inputs for values that differ per environment — CIDR blocks, instance sizes, feature flags — and keep module defaults for everything that should stay the same. If staging and prod inputs diverge in twenty places, your module boundary is wrong, not your Terragrunt layout.

prod/eks/terragrunt.hcl
dependency "vpc" {
config_path = "../vpc"
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
cluster_name = "prod"
}
Do not use Terragrunt to hide bad modules
If every env needs twenty special-case inputs, the module API is wrong. Fix the module interface. Terragrunt cannot save an unreadable module graph — it only removes boilerplate around modules that already make sense.
When Terragrunt helps vs hurts
Use it
Many envs, same modules
Shared backend/provider boilerplate
Git-pinned module versions
Clear live/ vs modules/ split
Skip or rethink
One account, three stacks
Team does not know Terraform yet
Generated files committed by mistake
Circular dependency blocks

Where this goes next

DRY config is only half the story. Gate plans with Checkov or OPA, lock state properly, and keep module versions boring. At scale, combine Terragrunt's generated backends with CI that runs terragrunt run-all plan on merge requests — surface drift before anyone applies. Pin the Terragrunt and Terraform versions in CI the same way you pin module refs. The Terragrunt course walks the full live-repo pattern end to end.

Go deeper in a courseTerragruntDRY live infrastructure across environments without losing Terraform clarity.View course

Related posts