Terragrunt: keeping 30 environments DRY
Stop copy-pasting backend and provider blocks across environments — generate them and keep inputs in one place.
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.
Terragrunt does not replace Terraform. It renders config, then shells out to terraform init/plan/apply with the right working directory.
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/root.hclprod/vpc/terragrunt.hcleks/terragrunt.hclstaging/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.
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 = truedynamodb_table = "acme-tf-locks"}}generate "provider" {path = "provider.tf"if_exists = "overwrite_terragrunt"contents = <<EOFprovider "aws" {region = "eu-west-1"default_tags {tags = { ManagedBy = "terragrunt" }}}EOF}
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"}
cd live/prod/vpc && terragrunt planDownloading 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.
dependency "vpc" {config_path = "../vpc"}inputs = {vpc_id = dependency.vpc.outputs.vpc_idcluster_name = "prod"}
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.