Home
Locals
Terragrunt vs Terraform: When to Use Each & When to Use Both

Terragrunt vs Terraform: When to Use Each & When to Use Both

with special guest
Mitchell
Hashimoto
Mitchell Hashimoto headshot

If you've worked with Terraform at scale, you've hit the wall: duplicated backend blocks across every environment, module calls that differ by a single variable, and a sprawling repo where changing one global default means editing twenty files. Terragrunt exists precisely to remove this friction.

But Terragrunt isn't for everyone. A solo developer spinning up a single-environment side project doesn't need it. A 40-engineer platform team managing six AWS accounts and three environments per service almost certainly does. This guide helps you figure out which side of that line you're on — and what to do when the answer is "somewhere in the middle."

 

1. What Problem Does Terragrunt Solve?

Terraform is excellent at what it's designed to do: declare infrastructure resources and manage their lifecycle. What it isn't designed for is managing many instances of the same infrastructure across multiple environments, accounts, or regions without repeating yourself.

In plain Terraform, every root module that manages remote state needs its own backend configuration. Every environment needs its own copy of provider configuration. There's no native way to inherit shared settings or compose configurations hierarchically. The result is a pattern engineers call "copy-paste infrastructure" — dozens of near-identical main.tf files that differ only in the value of a few variables.

Terragrunt is what you build when you've maintained a 200-module Terraform repo and realized the tooling wasn't designed for that reality.

Terragrunt addresses this with a set of core features:

•        Hierarchical configuration — Define common settings once in a root terragrunt.hcl and inherit them in child modules via find_in_parent_folders().

•        DRY backend configuration — Generate backend blocks dynamically from variables rather than hard-coding them per module.

•        Explicit dependency management — Declare that Module B depends on Module A, so Terragrunt can plan and apply them in the right order.

•        Run-all commands — Apply or destroy all modules in a directory tree in dependency order with one command.

•        Hooks — Run scripts before or after Terraform operations (e.g., validate inputs, notify Slack on destroy).

 

💡

Terragrunt is not a replacement for Terraform — it's a thin orchestration wrapper that calls Terraform under the hood. Every "terragrunt apply" still runs "terraform apply". Your state files, providers, and modules stay unchanged.

 

2. DRY Configurations Compared

The most immediate benefit Terragrunt provides is eliminating repeated backend and provider boilerplate. Here's how the same pattern looks with and without it.

Native Terraform: repeated backend blocks

In a typical multi-environment Terraform repo, every module has its own backend configuration:

This example shows a repeated Terraform backend configuration across multiple module directories. While functional, this pattern introduces duplication and increases the risk of inconsistencies across environments.

Terraform Backend (Repeated in Every Module)
# environments/prod/networking/main.tf
# This exact block is copy-pasted into every module directory

terraform {
backend "s3" {
bucket = "acme-terraform-state-prod"
key = "prod/networking/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "acme-terraform-locks"
encrypt = true
}
}

This approach works but scales poorly: every module must manually manage backend configuration. In larger systems, this is typically replaced with tools like Terragrunt to centralize state management, reduce duplication, and enforce consistency across environments.

 

Change the bucket name or region and you're editing every single one of these files. Miss one and you're debugging a silent state corruption hours later.

Terragrunt: generate it once

This setup demonstrates a centralized Terragrunt backend configuration. Instead of duplicating state configuration across modules, a single root file dynamically generates and injects backend settings for all environments.

Root Terragrunt Configuration (Shared Backend)
# root terragrunt.hcl

locals {
env = read_terragrunt_config(find_in_parent_folders("env.hcl"))
region = local.env.locals.aws_region
prefix = "acme-${local.env.locals.environment}"
}

remote_state {
backend = "s3"

config = {
bucket = "${local.prefix}-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = local.region
dynamodb_table = "${local.prefix}-terraform-locks"
encrypt = true
}

generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
Module Configuration (No Backend Duplication)
# environments/prod/networking/terragrunt.hcl

# That's it. The backend is fully inherited.

include "root" {
path = find_in_parent_folders()
}

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

inputs = {
vpc_cidr = "10.0.0.0/16"
name = "prod-vpc"
}

The include "root" block ensures every module inherits consistent state configuration. This eliminates duplication, reduces drift risk, and centralizes control of state buckets, locks, and naming conventions across all environments.

 

The path_relative_to_include() function automatically generates the correct state key for each module directory. Add a new environment by creating a new directory — no copy-pasting, no backend to configure.

Terraform Alone – Pros Terraform Alone – Cons (at scale)
Zero extra tooling Backend configuration is not DRY
Full HCL ecosystem support Provider blocks are repeated everywhere
Native workspace isolation No native cross-module dependency graph
Simpler onboarding Run-all requires wrapper scripts

3. Dependency Management

In pure Terraform, dependencies across root modules must be handled manually. If your eks module needs the VPC ID from your networking module, you have two options: hard-code the value, or look it up from the remote state file using a terraform_remote_state data source. Both approaches create fragility.

Terragrunt's dependency block

This Terragrunt configuration shows an EKS module depending on a networking layer. It uses mock outputs to support CI/CD workflows where upstream infrastructure may not yet exist during plan or validation stages.

EKS Dependency on Networking Module
dependency "networking" {
config_path = "../networking"

# Prevent failures in CI plan mode where outputs may not yet exist
mock_outputs = {
vpc_id = "vpc-00000000000000000"
private_subnets = ["subnet-0000000000000001"]
}

mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}

inputs = {
vpc_id = dependency.networking.outputs.vpc_id
subnets = dependency.networking.outputs.private_subnets
}

The mock_outputs_allowed_terraform_commands setting ensures safe CI execution without requiring real infrastructure. This pattern allows the EKS stack to be validated independently while still preserving strict dependencies for actual apply workflows.

The dependency block tells Terragrunt: "before applying this module, ensure the networking module has been applied, and read its outputs." When you run terragrunt run-all apply from the environment root, Terragrunt builds a DAG of all modules, resolves the order, and applies them in parallel where possible.

Mock outputs allow CI to run plan on modules whose dependencies haven't been applied yet. This keeps your pull request pipelines fast without needing a full environment provisioned.

 

To achieve the same thing in native Terraform, you'd need to use a terraform_remote_state data source, a separate orchestration layer like a Makefile, or Terraform Cloud's workspace-to-workspace references (which requires TFC). None of these are as ergonomic as Terragrunt's explicit dependency block.

 

4. Terragrunt vs Terraform Cloud

Terraform Cloud (TFC) and Terraform Enterprise (TFE) are HashiCorp's managed platforms for running Terraform at scale. They solve some of the same problems as Terragrunt — but through a different lens.

 

Capability Terraform Cloud Terragrunt
Remote State Management ✓ Native, built-in ✓ Works with any backend (S3, GCS, etc.)
Remote Plan/Apply Execution ✓ Managed runners Your own CI/CD pipelines
Policy Enforcement (OPA/Sentinel) ✓ Built-in Sentinel Requires third-party tools (e.g., OPA)
DRY Configuration Limited (variable sets help) ✓ Core feature
Cross-Module Dependencies Workspace references (Plus+) ✓ Native dependency blocks
Run-All Orchestration Not natively supported ✓ run-all commands
Cost Free → ~$20/user/month (Plus) Open source, free
UI & Audit Logs ✓ Full web UI CLI only (UI via env0/Atlantis-style tools)
Self-Hosting Enterprise only ✓ Fully self-hostable

Key insight: Terraform Cloud and Terragrunt are not mutually exclusive. Many large teams use both — Terragrunt for DRY local configuration, and a platform like env zero or TFC for remote execution, RBAC, and audit logging. The two tools operate at different layers of the stack.

⚠️

TFC's workspace-to-workspace run triggers (available on Plus plan) offer dependency chaining, but they're workspace-level — not module-level. For teams with fine-grained module decomposition, Terragrunt's DAG-based run-all remains more flexible.

 

5. Terraform Workspaces vs Terragrunt

Terraform workspaces are one of the most frequently misused features in the ecosystem. They're often marketed as the solution to "I need dev, staging, and prod environments" — but they have significant limitations at scale.

What workspaces actually do

Workspaces partition state within a single backend configuration. Running terraform workspace new staging creates a separate state file at a different key path. The code remains identical; you control environment differences through terraform.workspace interpolation and tfvars files.

The limitations

This pattern demonstrates a Terraform workspace-based environment switch. While it looks convenient, it becomes difficult to scale and maintain in real-world infrastructure.

Workspace-Based Configuration Mapping
locals {
env_config = {
dev = {
instance_type = "t3.small"
replica_count = 1
}

prod = {
instance_type = "r6g.2xlarge"
replica_count = 5
}
}

# This map grows unbounded as you add environments
config = local.env_config[terraform.workspace]
}
⚠️ This is commonly considered an anti-pattern in larger infrastructure setups because it tightly couples environment logic to Terraform state workspaces.

As environments grow, the env_config map becomes a central bottleneck, requiring constant updates and increasing risk of misconfiguration. Modern IaC setups (e.g., Terragrunt or separate state per environment) typically prefer explicit environment directories over workspace-based branching.

 

This works until you have 8 environments with 40 differing parameters. Then the locals block becomes a maintenance nightmare, and a terraform plan in one workspace can see all the configuration for every other workspace. There's no true isolation.

How Terragrunt handles environments

directory structure

This structure represents a clean environment-per-folder Terraform/Terragrunt layout. Each environment is isolated, explicitly defined, and independently deployable.

Environment-Based Directory Structure (Recommended Pattern)
environments/
├── dev/
│ ├── env.hcl # aws_region = "us-east-1", environment = "dev"
│ └── eks/
│ └── terragrunt.hcl
├── staging/
│ ├── env.hcl
│ └── eks/
│ └── terragrunt.hcl
└── prod/
├── env.hcl
└── eks/
└── terragrunt.hcl

Each environment has its own env.hcl, which defines region, naming conventions, and environment-specific variables. This avoids workspace ambiguity, improves isolation, and makes infrastructure behavior explicit and predictable across dev, staging, and prod.

 

Blast radius is naturally contained. Applying changes to prod/eks only touches prod's state. No workspace switching, no risk of running apply in the wrong workspace context.

📌

Rule of thumb: Use workspaces for short-lived, functionally identical environments (e.g., per-PR preview environments). Use Terragrunt's directory structure for persistent environments with meaningfully different configurations (dev vs prod).

 

6. Multi-Account Deployments

AWS multi-account architecture — where dev, staging, prod, security, and logging live in separate AWS accounts — is now considered best practice for enterprise workloads. It also makes naive Terraform usage deeply painful.

Terragrunt's approach: generate provider configs

This setup shows a multi-account AWS Terragrunt pattern where each account defines its own metadata, and the root configuration dynamically generates the AWS provider using that account context.

Account Configuration (prod/account.hcl)
locals {
account_id = "123456789012"
account_name = "prod"
aws_region = "us-east-1"
}
Root Terragrunt: Dynamic Provider Generation
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"

contents = <

The generate block ensures every module automatically receives a correctly configured AWS provider, scoped to the correct account and region. This removes duplication and enforces consistent cross-account access patterns.

Each module directory inherits the correct provider configuration for its account without any duplication. Adding a sixth AWS account means adding a new directory and an account.hcl file — not editing a dozen provider files.

The four-step pattern:

1.     Define account metadata — Each account folder has an account.hcl with its ID, name, and region.

2.     Generate providers dynamically — Root terragrunt.hcl uses the generate block to create provider.tf with the right assume-role ARN for each account.

3.     Isolate state per account — Remote state keys are namespaced by account and module path. No cross-account state contamination.

4.     Run-all across accounts — From the repo root, terragrunt run-all plan can report drift across all accounts in one command.

 

7. Performance

Performance is nuanced here because it depends heavily on what you're measuring.

Per-module operation speed

Terragrunt adds negligible overhead per module — it calls Terraform directly, and the HCL parsing Terragrunt does before handing off is measured in milliseconds. If you're running a single module, you will not notice the difference.

Parallelism at the repo level

This is where Terragrunt wins decisively. terragrunt run-all apply builds a DAG and applies independent modules in parallel, up to a configurable concurrency limit.

This demonstrates how Terragrunt can orchestrate infrastructure at scale using parallel execution and change-aware module selection.

Run All Modules in Parallel
# Apply all modules in parallel (max 10 concurrent)
terragrunt run-all apply --terragrunt-parallelism 10
Run Only Affected Modules (Change Detection)
# Only run modules that have changed (using source hash tracking)
terragrunt run-all apply --terragrunt-modules-that-include root.hcl
ℹ️ The --terragrunt-parallelism flag controls concurrency, while module filtering reduces unnecessary executions by targeting only impacted configurations.

Together, these options enable faster CI/CD pipelines and safer large-scale deployments by balancing speed (parallelism) with precision (selective execution).

Init caching

Terragrunt caches provider downloads and module sources in .terragrunt-cache. In CI environments with warm caches, this materially reduces init time — especially relevant for large repos with many modules that share providers.

⚠️

Cache gotcha: The .terragrunt-cache directory can grow large in CI. Configure your pipeline to either prune it between runs or cache it with a hash of your lockfile.

 

8. Team Size Considerations

The decision between vanilla Terraform and Terragrunt correlates strongly with team and infrastructure size. Here's a framework for thinking through the decision:

Use Terraform Alone If… Add Terragrunt When…
1–3 engineers managing infrastructure 3+ engineers or a dedicated platform team
Single AWS account or GCP project 2+ AWS accounts or moving toward multi-account architecture
1–2 environments (e.g., dev + prod) 3+ environments with differing configurations
Fewer than 20 root modules 20+ root modules with shared configuration patterns
Team is new to IaC — prioritize simplicity and lower cognitive load Backend configuration is being duplicated or copy-pasted
Using Terraform Cloud for remote execution already Cross-module dependencies are manual, fragile, or hard to manage

 

The "I'll add it later" trap

The most common mistake teams make is deciding to add Terragrunt "when we need it" — and then facing a painful migration when the repo is already large. The signal to add Terragrunt proactively is:

You are about to create a second environment or a second AWS account. That's the point at which the DRY benefits compound and the migration is still small.

Learning curve reality check

Terragrunt's HCL is very similar to Terraform's, but the mental model is different. Budget 1–2 days for an experienced Terraform engineer to become productive with Terragrunt, and 3–5 days to architect a well-structured Terragrunt repo from scratch.

 

9. env zero Support for Both

env zero fully supports both Terraform and Terragrunt, and handles the nuances of each natively.

Terraform on env0

env zero treats each Terraform root module as a workspace. You get remote plan/apply execution, RBAC, drift detection, cost estimation, and audit logs — all without managing your own runners.

Terragrunt on env0

•        Sub-module detection — env zero can discover all terragrunt.hcl files in your repo and create workspaces for each automatically.

•        Dependency-aware runs — When Terragrunt modules have explicit dependency blocks, env zero respects the ordering and runs them in the right sequence.

•        run-all support — You can trigger run-all apply scoped to a subset of your directory tree via env0's UI or API.

•        Version management — Pin Terragrunt and Terraform versions independently per project or globally.

•        RBAC on Terragrunt environments — Grant teams access only to the accounts and environments relevant to them, even within a monorepo.

 

Teams using env zero with Terragrunt can get the DRY code benefits of Terragrunt AND the UI, audit trail, and policy enforcement of a managed platform — without these being mutually exclusive.

 

The Decision in Three Lines

5.     Use Terraform alone if you have a small team, a single account, and fewer than 20 modules.

6.     Add Terragrunt when you're about to create a second environment or account — before the copy-paste debt accumulates.

7.     Use both with env0 to get DRY configuration, dependency management, remote execution, RBAC, and audit logs in one stack.

 

Putting It Together

Terraform and Terragrunt aren't competing philosophies — they're tools operating at different layers of the same problem. Terraform handles the hard work of resource lifecycle management, provider APIs, and state. Terragrunt handles the organizational overhead of managing many instances of that work across environments, accounts, and teams.

The teams that thrive with IaC at scale tend to converge on a similar pattern: well-structured Terraform modules (kept small and single-purpose), Terragrunt to compose and configure those modules across environments, and a platform layer for execution, visibility, and guardrails.

The most important thing isn't picking the "right" tool on day one. It's building with enough structure that adding Terragrunt later isn't a catastrophic refactor. Keep your modules small, avoid putting environment logic in module code, and isolate state per environment from the start.

Schedule a technical demo
See env zero in action
Schedule demo

Related Content

All articles