Why I Use Terragrunt Over Terraform/OpenTofu in 2025

Terragrunt vs Terraform: Why I chose Terragrunt to eliminate code duplication, automate state management, orchestrate deployments, and follow pattern-level reuse.
devops
iac
terraform
terragrunt
tool comparison
Author
Published

Aug 4, 2025

TL;DR
  • Terraform is painful to deal with on large infrastructures.
  • Code duplication, manual backend setup, and orchestration gets worse when your codebase grows.
  • Terragrunt is a wrapper over Terraform that solves these issues, but has a negative reputation.
  • I think this reputation is based on outdated information and misconceptions.
  • The new Terragrunt Stacks feature is game-changing.
  • It enables pattern-level infrastructure re-use. Something I’ve never seen before.
  • In 2025, most pain points of Terragrunt adoption are solved.

If you’ve managed Terraform across multiple environments, you know the pain: massive code duplication between dev, staging, and prod.

Manual backend state configuration. No built-in orchestration. Custom CI/CD scripts that break at the worst moments.

Luckily, I tried Terragrunt some years ago. I’m so glad I did!

It’s a wrapper over Terraform to avoid code duplication and orchestrate your Terraform modules.

I use it in all my projects now, and I’ll never go back.

Yet, when I discuss the “Terragrunt vs Terraform” topic with my peers on LinkedIn and Reddit, I’m always surprised by Terragrunt’s bad reputation.

It really doesn’t reflect my own experience at all. So I dug deeper to understand why people have such a negative feeling about it.

And guess what I found? Most of it stems from misconceptions and outdated knowledge from earlier versions.

In 2025, most of Terragrunt’s pain points have been addressed, and some game changer features have been released.

In this article, I’ll cover the following to explain why I chose Terragrunt over Terraform:

  1. The pain points of native Terraform.
  2. How Terragrunt solves these pain points.
  3. The new Terragrunt Stacks feature.
  4. Why most objections to Terragrunt adoption don’t hold up anymore.
  5. A feature comparison table on Terragrunt vs Terraform.

Want to jump straight to implementation? Use my terragrunt stack template and live template.

Terraform Pain Points In Production

Core Issues

Code Duplication

For managing multiple environments with Terraform, there are two possibilities:

  1. Using folder duplication.
  2. Using Terraform Workspaces.

Let’s start with the first option.

In this scenario, you would build your Terraform modules under the modules directory and create one directory per environment (dev, staging, and prod).

Resulting in the following tree:

project/
├── modules/
   └── vpc/
       ├── main.tf
       ├── variables.tf
       └── outputs.tf
├── environments/
   ├── dev/
   │   ├── provider.tf      # Duplicated
   │   ├── backend.tf       # Duplicated (different S3 key)
   │   ├── variables.tf     # Duplicated
   │   ├── outputs.tf       # Duplicated
   │   └── main.tf          
   ├── staging/
   │   ├── provider.tf      # Duplicated
   │   ├── backend.tf       # Duplicated (different S3 key)
   │   ├── variables.tf     # Duplicated
   │   ├── outputs.tf       # Duplicated
   │   └── main.tf          
   └── prod/
       ├── provider.tf      # Duplicated
       ├── backend.tf       # Duplicated (different S3 key)
       ├── variables.tf     # Duplicated
       ├── outputs.tf       # Duplicated
       └── main.tf          # module "vpc" { source = "../../modules/vpc" }

Each main.tf instantiates the vpc module. You can directly see the problem here: we need to redefine provider.tf, backend.tf, variables.tf, and outputs.tf in each environment folder.

That’s a lot of duplication! Any change to shared configuration needs to be manually copied to every environment.

Manual Backend State Setup

Following the example from the previous section, you would also need to write the backend configuration for each environment:

# dev/backend.tf
terraform {
  backend "s3" {
    bucket         = "myproject-terraform-state"
    key            = "dev/terraform.tfstate"
    region         = "us-west-2"
    dynamodb_table = "terraform-locks-dev"
  }
}

# staging/backend.tf  
terraform {
  backend "s3" {
    bucket         = "myproject-terraform-state"
    key            = "staging/terraform.tfstate"
    region         = "us-west-2"
    dynamodb_table = "terraform-locks-staging"
  }
}

Each environment needs its own unique S3 key to avoid state conflicts.

Here’s the worst: you can’t use variables here! Terraform simply doesn’t allow them in backend blocks.

You will also need to create the S3 bucket manually before anyone can run terraform init.

Usually, this means running a separate Terraform configuration just for the backend infrastructure, or clicking through the AWS console.

The whole process becomes a headache that only gets worse as you add more environments and team members.

You got it right! Each time you need to create an additional environment, you’ll need to copy and paste an environment folder and manually change all the keys.

No Built-in Orchestration

Terraform CLI can only work on one directory at a time. It has no idea that your modules depend on each other.

Here’s a typical application stack:

dev/
├── vpc/           # Must deploy FIRST
├── database/      # Must deploy SECOND (needs VPC)  
├── app-servers/   # Must deploy THIRD (needs database)
└── load-balancer/ # Must deploy FOURTH (needs app servers)

To deploy this stack, you have to manually run commands in dependency order:

cd dev/vpc && terraform apply
cd ../database && terraform apply  
cd ../app-servers && terraform apply
cd ../load-balancer && terraform apply

Following such an error-prone process in a production environment is not realistic.

HashiCorp introduced Terraform Stacks to address dependency management and orchestration problems. But it’s locked behind their paid Terraform Cloud platform.

Custom Scripts + CI/CD Is Not Ideal

Many teams think CI/CD pipelines will elegantly solve these Terraform limitations. I don’t believe it, and I’ll explain you why.

You still need custom scripts to handle orchestration logic. CI/CD just moves the complexity into your YAML pipeline:

name: Deploy Infrastructure
jobs:
  deploy:
    steps:
      - run: cd environments/dev/vpc && terraform apply -auto-approve
      - run: cd ../database && terraform apply -auto-approve  
      - run: cd ../app-servers && terraform apply -auto-approve
      - run: cd ../load-balancer && terraform apply -auto-approve

Using this solution, we port the orchestration logic in YAML instead of using purpose-built tools.

Unfortunately, we still have all the problems we discussed earlier:

  • Copy-paste directory structures
  • Manual backend configuration
  • No native dependencies between modules

Here’s another tedious scenario: when adding a new module or changing the dependencies, you need to rewrite your custom orchestration scripts manually.

I think CI/CD is excellent for deployment automation, but it’s the wrong approach for solving the architectural flaws of Terraform.

We’re simply adding another layer of complexity on top of the already existing problems.

Terraform Workspaces Are Flawed

Terraform Workspaces let you create separate state files for the same codebase. Each workspace maintains its own state while sharing the same configuration files.

Many engineers use this feature as a way to maintain a single configuration and change the backend’s state file path according to the workspace.

HashiCorp themselves don’t recommend this practice in their official documentation:

“Workspaces alone are not a suitable tool for system decomposition because each subsystem should have its own separate configuration and backend.” (source)

Workspaces only change state file names. They don’t solve the architectural problems we’ve been discussing.

Additionally, you’re not able to make your environment configurations diverge. For example, you need to use the same instances across dev and prod. In realistic scenarios, you’ll want to create smaller machines during development.

It’s also easy to forget that you’re not in your dev workspace anymore and inadvertently affect your prod environment.

Terraform Cloud? Not Always the Solution

Terraform Cloud Pricing: resource ramp-up very quickly with multi-environment setups | source: HashiCorp

Terraform Cloud Pricing: resource ramp-up very quickly with multi-environment setups (source)

Terraform Cloud (TFC) is HashiCorp’s paid, proprietary platform. For teams focused on open-source tooling, this immediately rules it out.

HashiCorp has introduced Terraform Stacks to address some orchestration concerns.

Stacks can help manage dependencies between components and reduce some of the manual coordination we’ve been talking about.

However, Stacks is still in beta, has limited deployment options (500 resources max), and requires expensive tiers for full access.

Terraform Cloud adds collaboration features and a web UI.

From my experience, it makes sense to invest in TFC when collaboration becomes a real need, especially for large DevOps teams.

For teams with less than 10 engineers, I think sticking to Terragrunt is a sound move: you get the same orchestration benefits without the vendor dependency or per-resource costs.

Terragrunt for Production in 2025

I don’t get excited about many tools, but Terragrunt genuinely transformed how I manage Infrastructure as Code.

Terragrunt is a wrapper over Terraform that tackles its limitations.

After dealing with all the problems I just described, it solved every major pain point in a way that felt elegant and maintainable.

To be clear, the discussion is not really “Terragrunt (TG) vs Terraform (TF)” but rather “native TF vs (TF + TG)”. As TG uses TF under the hood.

Let’s jump right into how it addresses each issue we’ve covered.

Terragrunt Solves Terraform’s Pain Points

Avoids Code Duplication

Terragrunt follows the Don’t Repeat Yourself (DRY) principle.

Instead of duplicating files across environments, you define shared configurations once and inherit them in child configurations.

Here’s what the same structure looks like with Terragrunt:

project/
├── root.hcl                 # Shared configuration
├── modules/
   └── vpc/
       ├── main.tf
       ├── variables.tf
       └── outputs.tf
└── live/
    ├── dev/
       └── terragrunt.hcl   # Only environment-specific values
    └── prod/
        └── terragrunt.hcl    # Only environment-specific values

The root.hcl defines the provider configuration once:

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}
EOF
}

# We'll cover remote_state configuration later

Each environment configuration instantiates the Terraform vpc module while changing environment variables:

# live/prod/terragrunt.hcl
include "root" {
  path = find_in_parent_folders("root.hcl")
}

locals {
  env = "prod"
}

terraform {
  source = "../../modules/vpc"
}

inputs = {
  environment       = local.env
  vpc_name          = "myproject-${local.env}"
  azs               = ["us-west-2a", "us-west-2b"]  # Availability zones list
}

The dev environment would be identical except env = "dev" and only one availability zone, for example azs = ["us-west-2a"].

include blocks let child configurations inherit everything from the parent. Terragrunt automatically generates the provider.tf file in each environment:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-west-2"
}

It prevents the duplication of the Terraform version, the AWS provider version, and the provider block.

Supports Automated Backend State Isolation

Remember the manual backend configuration nightmare from earlier? Terragrunt eliminates all of that.

You define the backend configuration once in your root.hcl file, and Terragrunt automatically handles unique state isolation for each environment:

# root.hcl

# Configuration from earlier plus:
remote_state {
  backend = "s3"

  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }

  config = {
    bucket         = "myproject-terraform-state"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "us-west-2"
    encrypt        = true
    dynamodb_table = "my-lock-table"
  }
}

It literally does the coffee for you: Terragrunt automatically creates the S3 bucket and DynamoDB table if they don’t exist.

No more bootstrap scripts or clicking manually on the AWS console!

The path_relative_to_include() function automatically generates unique S3 keys based on the directory structure.

Your prod environment produces prod/terraform.tfstate, while dev generates dev/terraform.tfstate. No manual key configuration, no copy-paste backend files!

Unlike Terraform’s backend blocks, Terragrunt can use dynamic values and local variables in backend configuration.

You write the backend setup once, and it works correctly across all environments without any manual intervention.

Has Built-in Orchestration

Remember the custom CI/CD scripts and manual orchestration from earlier? Terragrunt eliminates that entirely with native dependency management.

Here’s how you can structure related modules:

project/
├── root.hcl
├── live/
   ├── prod/
   │   ├── env.hcl              # Environment-specific values
   │   ├── database/
   │   │   └── terragrunt.hcl
   │   └── app/
   │       └── terragrunt.hcl
   └── dev/
       ├── env.hcl              # Environment-specific values
       ├── database/
       │   └── terragrunt.hcl
       └── app/
           └── terragrunt.hcl
└── modules/
   ├── app/
      ├── main.tf
      └── …
   └── database/
       └── main.tf

Environment-specific values are declared in one place:

# live/prod/env.hcl
locals {
  environment = "prod"
}

Here’s the database module with no dependency:

# live/prod/database/terragrunt.hcl
include "root" {
  path = find_in_parent_folders("root.hcl")
}
 
# Read the locals from env.hcl
locals {
  env = read_terragrunt_config(find_in_parent_folders("env.hcl")).locals.environment
}
terraform {
  source = "../../../modules/database"
}

inputs = {
  environment = local.env
  db_name     = "db-${local.env}"
}

The app module declares its dependency and uses the db_endpoint output from the database module:

# prod/app/terragrunt.hcl
include "root" {
  path = find_in_parent_folders("root.hcl")
}

# Read the locals from env.hcl
locals {
  env = read_terragrunt_config(find_in_parent_folders("env.hcl")).locals.environment
}


dependency "database" {
  config_path = "../database"
}

terraform {
  source = "../../modules/app"
}

inputs = {
  environment = local.env
  db_host     = dependency.database.outputs.db_endpoint
}

Terragrunt automatically understands that the database must deploy before the app.

One command deploys everything in the correct order:

terragrunt run-all apply

Much more elegant, isn’t it?

Terragrunt Stacks Is a Game-Changer

Gruntwork just released Stacks, a new feature that makes Terragrunt even DRYer!

I’m going to show you exactly how this changes everything.

Definitions

Example of a Terragrunt Stack | source: Gruntwork

Let me start with the basics first:

  • Unit: A Terragrunt wrapper around a Terraform module. Defines a single, deployable piece of infrastructure.
  • Stack: Defines a collection of related units that can be reused.

Making The Example Even DRYer

In the previous example, we still had to copy Terragrunt configurations in each environment directory. prod/app/terragrunt.hcl and dev/app/terragrunt.hcl were still duplicated.

With Terragrunt Stacks, we can factorize that too!

First, we will move prod/app/terragrunt.hcl and prod/database/terragrunt.hcl to the units directory without changing their content.

Next, we define the pattern (app + database) once in a stack file:

# stacks/web-app/terragrunt.stack.hcl
unit "database" {
  source = "git::git@github.com/yourorg/infrastructure-modules.git//units/database?ref=v1.0.0"
  path   = "database"
}

unit "app" {
  source = "git::git@github.com/yourorg/infrastructure-modules.git//units/app?ref=v1.0.0"
  path   = "app"
}

Then each environment just calls the stack with environment-specific values:

# live/dev/terragrunt.stack.hcl
stack "dev" {
  source = "git::git@github.com/yourorg/infrastructure-modules.git//stacks/web-app?ref=v1.0.1"
  path   = "services"
}

# live/prod/terragrunt.stack.hcl
stack "prod" {
  source = "git::git@github.com/yourorg/infrastructure-modules.git//stacks/web-app?ref=v1.0.0"
  path   = "services"
}

prod and dev are exactly identical. That’s right! Because our units search for the env.hcl file in parent directories, we don’t even have to specify the environment.

You can even point your dev stack to a more recent version v1.0.1 and keep prod on a stable one.

This is already a significant improvement. We’ve eliminated the last bits of duplication.

But honestly, I thought this was just a nice incremental upgrade.

I was wrong.

Nested Stacks

Nested Terragrunt Stacks example | source: Gruntwork

Here’s what I didn’t expect: Terragrunt Stacks can be nested.

For example, you could re-use your web-app stack and add a monitoring stack to it.

# live/prod/web-app-monitoring/terragrunt.stack.hcl
stack "web-app" {
  source = "git::git@github.com/yourorg/infrastructure-modules.git//stacks/web-app?ref=v1.0.0"
  path   = "services-web-app"
}

stack "monitoring" {
  source = "git::git@github.com/yourorg/infrastructure-modules.git//stacks/monitoring?ref=v1.0.0"
  path   = "services-monitoring"
}

Gruntwork is planning to incorporate the ability to use stack outputs as dependencies in the future. This will allow for even greater modularity.

Then it clicked: this isn’t just about eliminating copy-paste anymore.

Pattern Level Re-Use

This is the fundamental shift I completely missed at first.

With this new feature, platform teams aren’t just sharing Terraform modules anymore. They’re packaging and distributing complete infrastructure patterns.

Want to deploy a new microservice? Don’t think about databases, load balancers, monitoring, and networking separately. Just reference the microservice pattern and input your application-specific values.

Need a data pipeline? Reference the data pipeline pattern. It comes with ingestion, processing, storage, and observability already wired together.

Imagine the possibilities: creating a reusable stack that you could easily incorporate into your client’s infrastructures.

Want to learn more? Read Gruntwork’s Terragrunt Stacks article.

Terragrunt’s Pain Points

I get it. Terragrunt has a reputation for being complex and frustrating to work with.

Some of these objections are totally understandable, especially if your experience dates back to pre-2023.

But here’s my honest perspective on the main concerns people raise, and why I think the trade-offs are worth it in 2025.

Objection 1: Learning Curve

Terragrunt is known in the community to have a steep learning curve.

Yes, I must confess that the onboarding is harder initially.

The inheritance model and generative nature feel painful compared to copy-paste when you’re starting out.

However, I’ve been building and maintaining complex multi-environment infrastructures for RecSys. I’ve found it’s been totally fine!

I’ve even taught junior engineers to work and collaborate in such an environment.

The beauty is, once it clicks, the payoff is huge.

Objection 2: Poor Performance

I’ve found a recurring complaint that Terragrunt is slow and has performance issues.

Terragrunt v0.80 delivered 42% performance improvements, 43% memory reduction, and solved the O(n²) scaling issues that plagued earlier versions.

I’ve never had performance issues deploying and maintaining multiple environments with thousands of resources.

Objection 3: Difficult Debugging

Coming from Terraform, people may find the debugging process more difficult because Terragrunt has longer traces. It applies the infrastructure following a Directed Acyclic Graph (DAG).

I think it is certainly easier to debug than the custom CI/CD workflows needed to work around Terraform’s caveats.

Why I Think These Trade-offs Are Worth It

Look, I’m not going to pretend Terragrunt has zero learning curve or that every team should adopt it.

But when I see a 2025 article from Scalr ranking #1 on Google for Terragrunt drawbacks, I’m baffled to find that all the highlighted problems have been resolved since 2021-2022.

Unfortunately, this leads people to make decisions based on outdated information.

At the time of the writing, Terragrunt has 215 open issues versus 2,350 closed issues. That’s pretty solid for an open-source project.

Bottom line: for teams managing multiple environments with orchestrated deployments, I think Terragrunt is absolutely worth it.

Terraform vs Terragrunt: Comparative Feature Matrix

Here’s my attempt at building a Terragrunt versus Terraform table:

Dimension Terragrunt v0.80+ Terraform/OpenTofu
Workflow Simplicity High: One command deploys an entire stack of modules defined in one file. Low: Requires manual scripts or CI workflows to coordinate multiple modules.
Code Duplication Low: Hierarchical configs via include blocks. Stacks eliminate copy-paste across environments. Modules reused without duplication. High: Module reuse exists, but backend/provider configs must be duplicated.
Dependency Handling Automatic: Built-in dependency graph with dependency blocks. Manual: No inter-module automatic dependencies.
State Isolation Strong: Enforces one state per module. Auto-calculates unique remote state keys. Can auto-create backends. Small blast radius. Manual: State keys must be split manually. Large modules risk huge blast radius.
Performance Moderate: v0.80 delivered 42% faster execution. Still adds overhead, but now acceptable. Baseline: No wrapper overhead.
Testability Moderate: Works with Terratest. Can capture plan outputs for validation. No built-in test framework. High: Has provider mocking. Otherwise same testing approaches as Terragrunt.
Learning Curve High: Must learn Terragrunt syntax. Initial training investment needed, but day-to-day usage becomes simpler. Low: Most DevOps engineers are already familiar with Terraform.
Community Moderate: Active, engaged community with responsive maintainers. High: Massive community and learning resources.
TACOS Support Low: Integration with Atlantis is not trivial. Digger seems like a promising OSS alternative. For enterprise features, Gruntwork Pipeline has been designed for Terragrunt. High: Supported by most (if not all) TACOS platforms.

Terragrunt dominates features-wise (workflow, DRY, dependencies, state isolation) while Terraform/OpenTofu wins on familiarity, simplicity, performance, and TACOS support.

Final Takeaway

I’ll be honest, Terragrunt has genuinely made my infrastructure experience easier and more enjoyable.

When I read people complain about Terragrunt being “too complex” or “over-engineered,” I’ve realized they’re usually thinking about the 2019 version or relying on misconceptions.

What happened in 2025 changed the entire conversation.

Terragrunt evolved from a DRY orchestration tool to a pattern-level infrastructure platform. Meanwhile, Terraform remained focused on component-level management.

Even before Stacks, I used to rely on Terragrunt instead of Terraform for reducing duplication and orchestration. Now? They’re solving different problems entirely.

Terragrunt isn’t right for everyone. If you’re managing a single environment with few resources, native Terraform is probably fine. If you’re a small team that deploys infrastructure once a month, the learning curve might not be worth it.

But if you’re managing multiple environments, writing custom module orchestration, or constantly copying configuration between folders? Terragrunt solves this problem.

That’s exactly why I wrote this article. In the hope you find the same benefits in Terragrunt that I’ve found myself.

Give it a try, I’d love to hear about your experience!


Stay in touch

I hope you enjoyed this article as much as I enjoyed writing it!

Feel free to DM me any feedback on LinkedIn or email me directly. It will be highly appreciated.

That's what keeps me going!

Subscribe to get the latest articles from my blog delivered straight to your inbox!


About the author

Axel Mendoza

Senior MLOps Engineer

I'm a Senior MLOps Engineer with 6+ years of experience building production-ML systems.
I write long-form articles on MLOps to help you build too!