Terragrunt: keeping 30 environments DRY
Stop copy-pasting backend and provider blocks across environments — generate them and keep inputs in one place.
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/root.hcl # remote_state + generate: written onceprod/vpc/terragrunt.hcl # module version + inputs, nothing elseeks/terragrunt.hclstaging/vpc/terragrunt.hcleks/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
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.tfstateregion = "eu-west-1"encrypt = trueuse_lockfile = true # S3 native lock; no DynamoDB table}}generate "provider" {path = "provider.tf"if_exists = "overwrite_terragrunt"contents = <<EOFprovider "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.
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.
dependency "vpc" {config_path = "../vpc"mock_outputs = { vpc_id = "vpc-00000000" } # lets plan run before the VPC existsmock_outputs_allowed_terraform_commands = ["validate", "plan"]}inputs = {vpc_id = dependency.vpc.outputs.vpc_idcluster_name = "prod"}
cd live/prod/vpc && terragrunt planDownloading Terraform configurations from git::https://git.acme.dev/modules/vpc.git?ref=v1.4.2Plan: 12 to add, 0 to change, 0 to destroy.cd live && terragrunt run --all --filter-affected -- planthe units whose files changed between the default branch and HEAD, plus what depends on themterragrunt run --all --filter "prod/**" -- planeverything under prod, in dependency order; run-all is the older spelling of the same commandrun --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.
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