A while back I kept running into the same blog post, recycled under different titles: “you don’t need Terragrunt anymore, Terraform modules do everything it did.” Different authors, same argument. Terraform grew for_each on modules, got import blocks, got better state management. So the story goes: Terragrunt was a workaround for a young Terraform, and now that Terraform grew up, you can drop the wrapper and go back to plain HCL.
I’ve watched a handful of companies read that post and act on it. Rip Terragrunt out, migrate everything to “clean” Terraform modules, write a triumphant internal Slack message about it. A year later, some of those same teams are quietly rebuilding pieces of what they threw away, just with worse names and no docs.
Here’s my problem with the argument: it compares the wrong things. Terraform is a provisioning language. It’s very good at describing a pile of resources and reconciling reality against that description. Terragrunt was never trying to be a better version of that. It’s a composition tool: it decides what gets run, in what order, with what config, against which state. Modules answer, “How do I build a VPC?” Terragrunt answers, “How many VPCs do I have, where do they live, and how do I see all of them at once?” Those are different questions. Reading module for_each support as a replacement for Terragrunt is like reading “we added folders to our filesystem” as a replacement for Git.
I want to walk through why, using a real migration I just did. Not a toy example. An actual repo, actual GCP project, actual GitHub org, actual scars.
Read more: Terraform gives you modules. Terragrunt gives you a map.The setup almost everyone has
Picture the standard shape. One Terraform module. A vars/ folder next to it, one .tfvars file per environment: dev.tfvars, staging.tfvars, prod.tfvars. A wrapper script (or three) that does:
terraform init -backend-config=... terraform apply -var-file=vars/$ENV.tfvars.
Every environment is the same module, stamped out with different variable values.

It’s a clean idea, and it works, right up until it doesn’t. I initially built exactly this for my own project. GCP project per environment, a service account for GitHub Actions, workload identity federation so CI could authenticate without a static key sitting in a secrets manager somewhere. One module, dev.tfvars pointing at one project ID. Add prod.tfvars later, same shape, different values. Textbook.
Two things eventually break this picture. Neither one is exotic. Both of them show up regularly in setups I’ve encountered throughout my career.
Where does GitHub live?
The workload identity setup needs to know which GitHub repos are allowed to impersonate the service account. So somewhere, you need to manage that repo: branch protection, collaborators, Dependabot settings, the works. Fine, GitHub has a Terraform provider. Add it to the module.
Except now every environment’s module carries a github provider and repo resources that have nothing to do with that environment. Apply prod.tfvars, and Terraform tries to manage separate dev and prod copies of the exact same GitHub repository across two different state files, because each environment’s module is a full independent copy of everything, GitHub resources included. I hit this directly: the “workload identity binding” resource churned between two different for_each keys depending on which environment touched it last, because the two states disagreed about what the source of truth even was.

The honest answer to “Where does the GitHub configuration live?” in the one-module-per-env world is: nowhere good. I’ve seen four different bad answers to this across different companies.
- Bolt it onto whichever environment got built first (usually dev, so now “
dev” secretly owns your org-wide repo settings). - Duplicate it into every environment’s
tfvarsand hope nobody ever lets them drift. - Split it into a separate repo that ages badly because it’s disconnected from the thing it configures.
- Or just don’t manage it in Terraform at all, click it by hand, and let it rot.
There’s a fifth answer, and unlike the first four, and it’s a real improvement over the first four: split the shared infrastructure into its own root configuration and state. GitHub lives in shared/; environments live in envs/dev and envs/prod. Each concern gets a proper ownership boundary and its own state file.

But separation solves only half the problem. shared/ and envs/dev are now independent Terraform roots, while the WIF trust policy in dev still needs the exact list of repositories GitHub manages in shared/.
How does that data cross the boundary? Either someone copies it by hand and hopes nobody adds a repository without updating the other configuration, or you wire up a terraform_remote_state data source and hope whoever touches this in six months remembers that shared/ exists. Separate roots solve state isolation. Plain Terraform still doesn’t give you a first-class composition artifact showing how those roots fit together and which values they must agree on.

None of these are Terraform’s fault. Terraform doesn’t have an opinion about “which of my many identical modules is the one that also owns the shared stuff.” It can’t, because in this model there’s no first-class concept of “shared.” There’s only “the module” and “the vars.”
Where’s the map?
The second problem is quieter and, I think, worse in the long-term. Open this kind of repo cold and ask: what do we actually run? You can’t answer it by reading a file. You have to know that vars/ holds one file per environment, open each one, and mentally reconstruct the picture. The overview doesn’t exist as an artifact. It exists as tribal knowledge about a naming convention. Add a shared module outside this flow, and you are lost.
What actually changed my mind
I redid this same project on Terragrunt, specifically its newer Stacks feature, and the shift is bigger than “nicer backend config,” which is the usual pitch.
Here’s the entire project contract in a single file, to show you where I’m going:
locals {
github_owner = "lolipop"
github_repositories = {
backend: {...},
frontend: {...}
}
trusted_github_repositories = [
for name in keys(local.github_repositories) : "${local.github_owner}/${name}"
]
}
unit "github" {
source = "../units/github"
path = "github"
values = {
github_organization = local.github_organization
github_repositories = local.github_repositories
}
...
}
unit "dev" {
source = "../units/gcp-environment"
path = "dev"
values = {
github_repositories = local.trusted_github_repositories
}
}
unit "prod" {
source = "../units/gcp-environment"
path = "prod"
values = {
github_repositories = local.trusted_github_repositories
}
}
That’s the entire declared inventory for this repository. Every environment you run, and everything that isn’t an environment but still needs managing, sitting in one file, at a glance. That’s the ideology Terraform pitched from day one: describe the whole thing instead of clicking it into existence. Terragrunt is the first tool I’ve personally used that delivers this while preserving an independent state boundary for every unit. dev and prod both point at the identical gcp-environment template. github points at a completely different template because it isn’t an environment at all; it’s organization-level configuration that happens to live next to the environments instead of being smuggled inside one of them. One overview, one common understanding.
The one objection worth taking seriously
There’s one objection I’d take seriously if I were arguing the other side. You don’t actually need Terragrunt to get one file listing everything. Put module "github" { source = "./modules/github" ... }, module "dev" { source = "./modules/gcp-environment" ... }, and module "prod" { source = "./modules/gcp-environment" ... } in one root main.tf, with a locals block up top holding the repo list, and you get the same overview. One file, every environment, the shared GitHub config feeding all three. Plain Terraform, no extra tool.
I sketched exactly this before committing to Terragrunt, and it holds up until you ask where the state lives. Every module block in a root configuration shares that root configuration’s one state file. module.dev, module.prod, and module.github all become addresses inside a single `terraform.tfstate`, not three separate ones. That single fact undoes most of what made the migration worth doing. Applying dev now means Terraform touching a state file that also holds prod. There’s no clean way to run just one environment without -target, a flag Terraform’s own docs actively warn you away from building a workflow around. One backend for the whole thing, so github can’t get its own bucket the way it does now. I also want to be able to run terragrunt plan for dev without setting a GITHUB_TOKEN. In one big Terraform root, that stops being simple because the GitHub resources are part of the same plan.
So here’s the honest version of the claim: Terraform modules give you the overview just fine, the moment you nest them in one root config instead of running N separate -var-file invocations. What they don’t give you is a choice about state and other composition logic. Terraform’s module system composes configuration, not state, and one file listing everything only happens if you’re willing to put everything in one state too. Terragrunt Stacks refuses to make you choose: one composed contract on top, with independent state and backends underneath, one per unit. That’s the actual gap plain modules leave open, not the one the recycled blog post argues about.
That single file earns its keep operationally too, not just as a reference. Run terragrunt stack run plan from the top, and Terragrunt runs a separate plan for every unit in the stack—github, dev, and eventually prod—and aggregates the results under one command. It isn’t one atomic Terraform plan or transaction: every unit still has its own plan and state boundary. That independence is the point.
The first run against a brand-new backend requires one additional flag, --backend-bootstrap. Terragrunt can then create the GCS bucket instead of forcing you to hand-roll a bootstrap.sh script first.
Or work at the other altitude: cd into .terragrunt-stack/dev and run a plain terragrunt plan there, and you’re touching exactly that one unit. No other unit’s output mixed into the log, no risk of accidentally planning something you didn’t mean to touch. Same source files either way, same generated units. You just pick which altitude you want to operate from, and the tool doesn’t force you into one or the other.
The bold version of this claim
I’ll say the thing plainly instead of hedging it: most companies doing IaC today are running the vars-folder pattern, calling it “our Terraform setup,” and have never once had a single artifact that shows their entire infrastructure inventory at a glance. Not a wiki page. Not a diagram someone drew eight months ago and never updated. An actual executable inventory of everything the repository declares it owns—one that can fail when its declared inputs, relationships, or configurations are wrong. It cannot detect infrastructure created manually or managed from another repository, but within this repository it is the authoritative map.
That’s not a Terraform limitation people have quietly worked around for years.It’s a missing layer, and most teams don’t know it’s missing because they’ve never had anything to compare it against. I hadn’t, until I built this. I’d defend this setup in front of anyone who’s read the “ditch Terragrunt” post and nodded along. Composition and provisioning are different jobs. Once you’ve felt what it’s like to have both, going back to “just modules” feels like giving up version control because your editor has good undo.
HashiCorp now has its own product called Terraform Stacks, which tackles much of the same missing composition layer. That actually reinforces the argument: modules alone were never the whole answer. But the state model is different. Terraform Stacks run through HCP Terraform or Terraform Enterprise, and every deployment has one state containing all of its components. Terragrunt Stacks lets github, dev, and prod remain independently stateful units while still composing them in one file. This article is about that particular combination: one map above multiple operational and state boundaries.
Practical setup
So, what do I actually have, and how is everything wired together? Below is an example of the structure of my IaC folder. It is not complete, but it should give you a sense of the setup:

Rule of thumb 1: where does a piece of config live?
Ask one question: is this the same for every instance of one template, or does it need to be agreed on across different templates?
- Same across every instance of one template. Examples include the list of APIs to enable, secret names for
gcp-environment, and internal IPs that never leave the project. These belong inside the template itself as plain locals. The template’s own definition is already the shared thing by construction; you don’t need an external file to make N copies of the same thing agree with each other. - Needed by two different templates. Repository names used by both
githubandgcp-environment, for example, are genuinely cross-template and belong at the top, wherever your shared configuration lives. Keep it in the stack’s own locals as long as you only have one stack; promote it to a real shared file the moment a second stack needs to agree on the same data. - Specific to one instantiation. A value such as
project_id, which differs betweendevandprod, belongs in that unit’svaluesblock and nowhere else.
Rule of thumb 2: Naming
Inside gcp-environment, the variable is project_id. Not dev_project_id. The template has no business knowing it’s about to become “dev,” so don’t let it know. All the concrete, named vocabulary (dev_project_id, prod_project_id) lives one level up, in the stack file that instantiates the template. Two altitudes, two vocabularies, on purpose. Collapsing them (one shared project_id used by two things that only happen to match today) is how you get a silent, false coupling that breaks the moment a third thing needs its own value. You can read more about this in the next section, “Two names, one value.”
Rule of thumb 3: if a unit consumes it, the stack file should show it
While it might be tempting to use read_terragrunt_config from within a unit to retrieve shared variables, I’d still avoid it for two reasons.
- The stack file stops being the map. The whole point of
stacks/terragrunt.stack.hclis that it is the one place that shows everything: every unit, every environment, and every input each one actually depends on. Open theunit "dev" { values = { ... } }block, and you can see immediately thatdevneeds aproject_id, a service account config, a list of trusted GitHub repos. If instead a unit quietly reaches out and reads some other file on its own, that dependency disappears from the stack file entirely. You’d only find it by opening the unit itself and reading its internals. The map stops being the map the moment a unit can pull in data the map never mentions. You could move the unit configuration into its own file if that is easier, but it should still live at the stack level—in thestacksfolder—not at the unit level in theunitsfolder. - Second, and this one matters more the longer you live with it: a unit that reaches outside itself can’t be moved. If a unit only ever receives data through its own values, it doesn’t care where it lives. Copy that folder into its own git repo tomorrow, point
sourceto it using a URL instead of a local path, and it keeps working exactly as before, because it never assumed anything about its surroundings. The moment a unit reads a file sitting two directories up on its own, it’s secretly coupled to one specific repo layout, and moving it means finding and fixing that assumption first.valuesis the only interface a unit has with the outside world, on purpose, so the unit itself can be picked up and dropped anywhere without noticing.
Also, always validate your inputs with terragrunt hcl validate --inputs --strict. It will save you a lot of headaches later.
Rule of thumb 4: keep root.hcl even when it has nothing in it
You can use it for configuration, or it can be empty, but keep it. It’s a landmark, not necessary a data file. One of its jobs is to mark where the Terragrunt-managed tree starts, and Terragrunt’s find_in_parent_folders() walks the filesystem looking for that exact name—nothing more. It has no idea what Git is. Some “find my project root” tools anchor on .git instead, which quietly breaks the moment your code arrives some other way: as a tarball, a copied CI artifact, or a Docker build context that contains only a subtree.
Rule of thumb 5: importing existing infra
- Put import { to = …, id = “…” } blocks in their own throwaway import.tf, not scattered inline. Easy to spot, easy to delete the moment the apply succeeds.
- Always plan before apply on an import. A clean import shows
0 to change; if it doesn’t, stop and figure out why before touching anything real.. - Don’t trust the id attribute you see in a plan/refresh as the import ID. They often differ. google_project_iam_member/google_service_account_iam_member need a space-separated “resource role member” string for import, even though their stored id is slash-joined. Check the provider’s actual import code/docs first.
For example, this is theidofgoogle_project_iam_member:lolipop-amulet-111111/roles/cloudbuild.builds.editor/serviceAccount:github-actions@lolipop-amulet-111111.iam.gserviceaccount.com
But inside the import block, it looks like this:import {
to = google_project_iam_member.github_actions_roles["roles/cloudbuild.builds.editor"]
id = "lolipop-amulet-111111 roles/cloudbuild.builds.editor serviceAccount:github-actions@lolipop-amulet-111111.iam.gserviceaccount.com"
}
See the spaces inid? Neither did I, but Terraform does. 😉 - Some fields are immutable after creation (google_secret_manager_secret.replication, for example). Check the actual live value before writing the resource; an import does not merely show a diff for a mismatch there—it fails completely.
Rule of thumb 6: the two speeds you get for free
terragrunt stack run plan/apply from the top walks every unit in the stack in one shot.
cd into one generated unit and run plain terragrunt plan/apply, and you are touching exactly that one thing. The same source files are used either way. Pick the altitude you need for each task; the tool does not force you to choose one permanently.
Two names, one value
Before I finish, I’d like to tell you a short story about how this setup gave me a better overview. Because I had to declare everything in one place, the structure became clearer and started reflecting reality more accurately than the old “clone of an environment” model did.
My first draft of the stack file did the obvious DRY thing. One value, defined once, reused wherever it applied:
locals {
project_id = "my-project-id"
}
unit "github" {
values = { project_id = local.project_id, ... }
}
unit "dev" {
values = { project_id = local.project_id, ... }
}
github and dev share a project_id right now for exactly one reason: the GitHub state bucket happens to live in the same GCP project as dev. That’s a fact about where I happened to put a bucket, nothing more.
So far, it looked clean—until I started sketching out prod. It needs its own project ID, one that has nothing to do with dev. A single shared project_id local has no way to say “these two agree by coincidence, that third one is different by design.” It can only say “these are the same.” Exactly the kind of value that looks tidy on day one and turns into a landmine on day 200, when someone “cleans it up” and quietly repoints dev’s provider at github’s bucket, or the other way around.
The fix: stop sharing the name.
locals {
dev_project_id = "my-project-id"
github_project_id = "my-project-id" # same bucket as dev, for now - not the same thing
prod_project_id = "my-prod-project-id"
}
Same two values today. But now the file says, out loud, that these are two independent facts that currently happen to agree, instead of one fact wearing two hats. Add prod_project_id next to them later and nothing about dev or github has to change, because nothing about them was ever coupled. Only the values happened to match.
There’s a second layer here that actually surprised me. Inside the gcp-environment template itself, the one both dev and prod reuse, the variable is just project_id. Generic. The template has no business knowing it’s about to become “dev,” so it doesn’t get an opinion. One level up, in the stack file that instantiates it, that same value gets a name with the environment baked in: dev_project_id. Two names, two altitudes, on purpose. The template speaks in nouns generic enough to serve anything. The composition speaks in nouns specific enough to describe the one real thing sitting in front of you. Force the template to know it’s dev and you can’t reuse it for prod. Force the composition to stay generic and you can’t tell dev and prod apart by reading the file. You need both altitudes, and you need them to use different words.
Comments are closed.