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.
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.
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.tfin the root modulevars-optional.tfin the root module.auto.tfvarsto 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.
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.
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 guide follows a sales structure:
- Start simple — establish credibility
- Introduce “best practices” — variable passthrough everywhere
- Scale the pattern — multiply the boilerplate
- Present the problem — “look at all this duplication”
- Sell the solution — Terragrunt
The hidden assumption in step 2 makes Terragrunt necessary. Without it, plain OpenTofu handles multi-environment deployments fine.
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 applyBut honestly, just copy the backend block. It’s 6 lines. You’ll touch it once per environment, ever.
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.
Terragrunt’s run --all:
terragrunt run --all applyBash:
for dir in live/*/; do (cd "$dir" && tofu apply -auto-approve); doneOne line. Works if your modules are independent.
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-ec2make -j4 apply # parallel where possible, respects depsMake has been solving dependency graphs since 1976.
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.
| 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 |
- Many cross-state dependencies: If you have 50+ states referencing each other,
terraform_remote_stateblocks everywhere get tedious. Terragrunt’sdependencyis cleaner. - You need mock_outputs: If your workflow requires planning modules before dependencies exist, there’s no alternative.
- 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.
The Terragrunt guide teaches a pattern that creates problems, then sells you the tool that solves them.
If you’re starting a new project:
- Inline values in module calls
- Use locals only for values that repeat within a file
- Copy files between environments — it’s fine
- 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.”