Skip to content

Instantly share code, notes, and snippets.

@bofm
Created December 26, 2025 18:58
Show Gist options
  • Select an option

  • Save bofm/e8fc522df08ea659c1811dd8411bc6c9 to your computer and use it in GitHub Desktop.

Select an option

Save bofm/e8fc522df08ea659c1811dd8411bc6c9 to your computer and use it in GitHub Desktop.
Terragrunt not needed.md

The Terragrunt Guide Creates the Problem It Solves

Gruntwork’s “Terralith to Terragrunt” guide is well-written and comprehensive. It’s also a masterclass in manufacturing complexity to sell a solution.

I walked through the guide step by step. Here’s where it goes wrong — and what to do instead.

Step 1: A Reasonable Start

You begin with flat resources in a live/ directory. S3 bucket, DynamoDB table, IAM role, Lambda function. All in one state file. Simple.

live/
├── backend.tf
├── ddb.tf
├── iam.tf
├── lambda.tf
├── s3.tf
└── providers.tf

Nothing wrong here. It’s how most projects start.

Step 2: The Poison Pill

The guide introduces “best practice” refactoring. You extract each resource type into a module under catalog/modules/. Sounds reasonable.

But look at what they actually do:

# catalog/modules/lambda/vars-required.tf
variable "name" {
  type = string
}
variable "aws_region" {
  type = string
}
variable "lambda_zip_file" {
  type = string
}
variable "lambda_role_arn" {
  type = string
}
variable "s3_bucket_name" {
  type = string
}
variable "dynamodb_table_name" {
  type = string
}

Every input is a variable. Then in the root module:

# live/main.tf
module "lambda" {
  source              = "../catalog/modules/lambda"
  name                = var.name           # passthrough
  aws_region          = var.aws_region     # passthrough
  lambda_zip_file     = var.lambda_zip_file # passthrough
  lambda_role_arn     = module.iam.arn
  s3_bucket_name      = module.s3.name
  dynamodb_table_name = module.ddb.name
}

Notice that var.name, var.aws_region, and var.lambda_zip_file are pure passthrough. The root module declares these variables just to forward them to child modules. This creates:

  • vars-required.tf in the root module
  • vars-optional.tf in the root module
  • .auto.tfvars to set values

Three files exist purely to shuttle values through an abstraction layer.

What they should have done — inline the values:

module "lambda" {
  source              = "../catalog/modules/lambda"
  name                = "my-app-prod"
  aws_region          = "eu-central-1"
  lambda_zip_file     = "../dist/app.zip"
  lambda_role_arn     = module.iam.arn
  s3_bucket_name      = module.s3.name
  dynamodb_table_name = module.ddb.name
}

No variables file. No tfvars. The configuration is the configuration.

Step 3-4: Manufacturing the Crisis

After establishing the variable passthrough pattern, the guide splits environments. Now you have live/dev/ and live/prod/.

Each directory needs:

File Content
main.tf Identical
outputs.tf Identical
providers.tf Identical
vars-required.tf Identical
vars-optional.tf Identical
versions.tf Identical
backend.tf Differs only in state key
.auto.tfvars Differs only in name

Eight files per environment. Six are identical. Two have trivial differences.

The guide then says:

“There’s some annoying boilerplate that’s inconvenient to create and maintain.”

Yes. Boilerplate you just created by following their pattern.

Step 5: The Sale

Enter Terragrunt. It solves the boilerplate problem with include blocks, generate blocks, and inputs maps.

But the boilerplate only exists because of Step 2’s design. Without variable passthrough, each environment is just:

# live/prod/main.tf
terraform {
  backend "s3" {
    bucket = "my-tf-state"
    key    = "prod/terraform.tfstate"
    region = "eu-central-1"
  }
}

provider "aws" {
  region = "eu-central-1"
}

module "s3" {
  source = "../../modules/s3"
  name   = "my-app-prod"
}

module "ddb" {
  source = "../../modules/ddb"
  name   = "my-app-prod"
}

module "iam" {
  source             = "../../modules/iam"
  name               = "my-app-prod"
  aws_region         = "eu-central-1"
  s3_bucket_arn      = module.s3.arn
  dynamodb_table_arn = module.ddb.arn
}

module "lambda" {
  source              = "../../modules/lambda"
  name                = "my-app-prod"
  aws_region          = "eu-central-1"
  lambda_zip_file     = "../../dist/app.zip"
  lambda_role_arn     = module.iam.arn
  s3_bucket_name      = module.s3.name
  dynamodb_table_name = module.ddb.name
}

output "url" {
  value = module.lambda.url
}

One file. Copy to dev/, change my-app-prod to my-app-dev and the backend key. Done.

“But my-app-prod repeats! And eu-central-1 repeats!”

Fine. Use a local for things that actually repeat within the file:

locals {
  name   = "my-app-prod"
  region = "eu-central-1"
}

module "lambda" {
  source     = "../../modules/lambda"
  name       = local.name
  aws_region = local.region
  # ...
}

Still one file. Two lines of locals. No variable passthrough chain across files.

The Pattern

The guide follows a sales structure:

  1. Start simple — establish credibility
  2. Introduce “best practices” — variable passthrough everywhere
  3. Scale the pattern — multiply the boilerplate
  4. Present the problem — “look at all this duplication”
  5. Sell the solution — Terragrunt

The hidden assumption in step 2 makes Terragrunt necessary. Without it, plain OpenTofu handles multi-environment deployments fine.

What About the Other Terragrunt Features?

Backend Config Without Duplication

If you really hate repeating the backend block, use -backend-config:

terraform {
  backend "s3" {}
}
#!/bin/bash
cd "live/$1"
tofu init \
  -backend-config="bucket=my-tf-state" \
  -backend-config="key=$1/terraform.tfstate" \
  -backend-config="region=eu-central-1"
tofu "${2:-plan}"
./deploy.sh prod apply

But honestly, just copy the backend block. It’s 6 lines. You’ll touch it once per environment, ever.

Cross-State Dependencies

Terragrunt’s dependency block:

dependency "vpc" {
  config_path = "../vpc"
}
inputs = {
  vpc_id = dependency.vpc.outputs.vpc_id
}

Plain OpenTofu uses terraform_remote_state:

data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket = "my-tf-state"
    key    = "prod/vpc/terraform.tfstate"
    region = "eu-central-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.vpc.outputs.subnet_id
}

Yes, your module now knows about the state bucket. That’s the trade-off. If it bothers you, Terragrunt helps here. If not, move on.

Batch Operations

Terragrunt’s run --all:

terragrunt run --all apply

Bash:

for dir in live/*/; do (cd "$dir" && tofu apply -auto-approve); done

One line. Works if your modules are independent.

DAG Execution

If modules have dependencies (vpc before ec2), Terragrunt builds a graph and runs in order.

Make does this:

apply-vpc:
	cd live/vpc && tofu apply -auto-approve

apply-sg: apply-vpc
	cd live/sg && tofu apply -auto-approve

apply-ec2: apply-vpc apply-sg
	cd live/ec2 && tofu apply -auto-approve

apply: apply-vpc apply-sg apply-ec2
make -j4 apply  # parallel where possible, respects deps

Make has been solving dependency graphs since 1976.

mock_outputs

This is the one Terragrunt feature with no clean workaround.

Terragrunt lets you plan a module before its dependencies exist:

dependency "vpc" {
  config_path = "../vpc"
  mock_outputs = {
    vpc_id = "vpc-mock"
  }
}

With terraform_remote_state, if the vpc state doesn’t exist, plan fails.

The workaround: apply in order. VPC first, then things that depend on it. For most workflows, this is fine.

Comparison

Terragrunt Feature Plain OpenTofu
inputs = {} Inline values in module calls
generate "backend" -backend-config or just copy the block
generate "provider" Inline in file
dependency block terraform_remote_state data source
run --all for dir in */; do ... done
DAG execution Makefile with prerequisites
mock_outputs No equivalent — apply deps first
include / inheritance Locals in each file

When Terragrunt Actually Helps

  1. Many cross-state dependencies: If you have 50+ states referencing each other, terraform_remote_state blocks everywhere get tedious. Terragrunt’s dependency is cleaner.
  2. You need mock_outputs: If your workflow requires planning modules before dependencies exist, there’s no alternative.
  3. Large teams with strict conventions: Terragrunt enforces structure. Everyone’s config looks the same.

For 2-10 environments with independent or simple dependencies, plain OpenTofu is simpler.

Conclusion

The Terragrunt guide teaches a pattern that creates problems, then sells you the tool that solves them.

If you’re starting a new project:

  1. Inline values in module calls
  2. Use locals only for values that repeat within a file
  3. Copy files between environments — it’s fine
  4. Use a shell loop or Makefile for batch operations

Add Terragrunt when you genuinely need cross-state dependency management at scale or mock_outputs for complex CI workflows.

Don’t add it because a guide convinced you that variable passthrough is a “best practice.”

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment