If you are asking whether to add Terragrunt, you have probably already written the same Terraform three times. Once for staging, once for production, and once for the EU region you spun up last quarter. The backend blocks are near-identical, the module calls differ by a handful of inputs, and you have started copy-pasting main.tf between directories and praying the diff stays small.
That copy-paste problem is exactly what Terragrunt exists to solve. Terragrunt is not a competing IaC engine. It is a wrapper that calls Terraform (or OpenTofu) for you and removes the repetition that plain HCL forces on multi-environment setups. So the real question has less to do with "Terragrunt or Terraform" and more to do with whether your infrastructure has grown big enough that hand-written HCL has started to hurt.
This article shows the exact repetition Terragrunt removes, the config that replaces it, and where the line sits.
Terragrunt is a binary that shells out to terraform. When you run terragrunt apply, it reads a terragrunt.hcl file, generates the backend and provider configuration into a temporary directory, injects inputs, and then runs terraform apply in that directory. Everything Terraform does, Terraform still does. Terragrunt sits one layer above.
It was built by Gruntwork to patch four specific gaps in raw Terraform:
backend block, and that block cannot use variables. So the bucket, key, and region get hardcoded per directory.terraform.tfvars.plan across ten interdependent modules in the right order means either a shell script or a lot of manual cd.terraform_remote_state data sources everywhere.None of these are bugs in Terraform. They are the natural friction of a tool designed around a single root module with a static backend. Terragrunt is the community's answer to running many root modules at scale.
| Terraform | Terragrunt | |
|---|---|---|
| What it is | IaC engine (plan/apply against providers) | Wrapper that invokes the Terraform/OpenTofu binary |
| Provisions resources | Yes | No, delegates to Terraform |
| Config language | HCL | HCL (terragrunt.hcl), then generates Terraform HCL |
| Backend config | Static block per root module | Generated, DRY, one definition inherited by all |
| Multi-environment | Workspaces or copied directories | File-tree of thin terragrunt.hcl units |
| Cross-module outputs | terraform_remote_state data sources | dependency blocks with mock outputs |
| Run across many modules | Manual or scripted | terragrunt run-all plan/apply |
| Learning curve | Baseline | Baseline plus Terragrunt's own concepts |
| Extra dependency | None | A second binary to install and version |
Here is a fairly ordinary setup: one reusable module, deployed to staging and prod, each in its own state file.
You end up with a directory per environment, and most of the content is duplicated.
# environments/staging/main.tf
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "staging/services/main.tfstate"
region = "eu-west-1"
dynamodb_table = "acme-tf-locks"
encrypt = true
}
}
module "service" {
source = "../../modules/service"
environment = "staging"
instance_type = "t3.small"
min_size = 1
max_size = 2
account_id = "111111111111"
tags = { team = "platform", env = "staging" }
}
# environments/prod/main.tf
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "prod/services/main.tfstate"
region = "eu-west-1"
dynamodb_table = "acme-tf-locks"
encrypt = true
}
}
module "service" {
source = "../../modules/service"
environment = "prod"
instance_type = "m5.large"
min_size = 3
max_size = 10
account_id = "222222222222"
tags = { team = "platform", env = "prod" }
}
The backend block is identical except for the key. The account_id, region, and tags follow a mechanical pattern. Add a third environment and you copy this file again. Change the lock table name and you edit it in every directory. Terraform cannot interpolate variables into a backend block, so this stays hardcoded no matter how disciplined you are.
Terragrunt splits the shared parts out. A single root terragrunt.hcl defines the backend once and generates it into each unit.
# terragrunt.hcl (repo root)
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite"
}
config = {
bucket = "acme-tfstate"
# path_relative_to_include() -> "staging/services", "prod/services", etc.
key = "${path_relative_to_include()}/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "acme-tf-locks"
encrypt = true
}
}
Environment-level common inputs live in one file per environment:
# staging/env.hcl
locals {
environment = "staging"
account_id = "111111111111"
}
And each deployed unit becomes small enough to read at a glance:
# staging/services/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
locals {
env = read_terragrunt_config(find_in_parent_folders("env.hcl")).locals
}
terraform {
source = "../../modules/service"
}
inputs = {
environment = local.env.environment
account_id = local.env.account_id
instance_type = "t3.small"
min_size = 1
max_size = 2
tags = { team = "platform", env = local.env.environment }
}
The prod/services/terragrunt.hcl is the same shape with different instance sizes. The backend block does not appear in it at all, because Terragrunt generates backend.tf from the root config and derives the state key from the folder path. Change the lock table name once in the root and every unit picks it up.
That is the whole value proposition. Less HCL, one source of truth for backend and shared inputs, and units that describe only what makes them different.
The other pain Terragrunt targets is passing outputs between separately-stated modules. In raw Terraform you reach into another state file:
data "terraform_remote_state" "vpc" {
backend = "s3"
config = {
bucket = "acme-tfstate"
key = "prod/vpc/terraform.tfstate"
region = "eu-west-1"
}
}
module "service" {
source = "../../modules/service"
vpc_id = data.terraform_remote_state.vpc.outputs.vpc_id
subnet_ids = data.terraform_remote_state.vpc.outputs.private_subnet_ids
}
Terragrunt replaces that with a dependency block, which also knows how to supply mock outputs during plan so you can plan the whole graph before anything is applied:
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-mock"
private_subnet_ids = ["subnet-mock-a", "subnet-mock-b"]
}
mock_outputs_allowed_terraform_commands = ["plan", "validate"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.private_subnet_ids
}
Then terragrunt run-all apply walks the dependency graph and applies in the correct order. On a raw Terraform setup that ordering is a shell script or a human running apply in the right sequence and hoping.
Reaching for Terragrunt is a signal, and it is worth reading.
Choose Terragrunt when:
terraform_remote_state plumbing.run-all to plan and apply an ordered graph in one command.Stay on plain Terraform when:
backend block plus a tfvars file per environment is fine, and it is one fewer binary to install, version, and teach.Terragrunt is very good at what it does, and Gruntwork has kept it current, including OpenTofu support. But adding it means every engineer now needs to understand both the Terraform mental model and Terragrunt's generate-and-include model on top. That is a fair trade at scale and dead weight on a small project.
Step back from the two tools for a second. The entire reason Terragrunt exists is that describing the same backend across N environments, in a language that cannot loop over environments, produces N copies of nearly-identical config. Terragrunt is a very capable de-duplicator for that problem.
It is worth noticing that the problem is structural, not accidental. Infrastructure lives in its own codebase, in HCL, against its own state files, with no knowledge of the application it supports. Every environment is a fresh copy of that description. Terragrunt reduces the copy count, but the model that generates copies in the first place stays put.
A different model avoids the repetition rather than de-duplicating it. Infrastructure from code means you declare what your application needs (a database, a Pub/Sub topic, a cron job, a bucket) directly in the application's own source, and the tooling provisions the matching cloud resources per environment from that single declaration. There is no per-environment HCL to keep DRY because there is no per-environment HCL at all.
Encore is the open-source framework built on this idea, for TypeScript and Go, with around 12,000 GitHub stars and production users including Groupon.
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
// One declaration. Encore provisions Postgres (RDS on AWS, Cloud SQL on GCP,
// Docker locally) separately for every environment: local, each PR preview,
// staging, and production. No per-environment config to keep in sync.
const db = new SQLDatabase("users", { migrations: "./migrations" });
export const getUser = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: { id: number }) => {
return await db.queryRow`SELECT id, email FROM users WHERE id = ${id}`;
},
);
Encore Cloud provisions the underlying AWS or GCP services in your own account, and the framework is open source, with encore build docker producing a standalone image if you want out. This is not a wholesale replacement for Terraform. Teams running large VM fleets, complex networking, or IAM at scale will keep using IaC, and Terragrunt with it. What infrastructure from code removes is exactly the per-environment configuration sprawl that Terragrunt is built to manage, which makes it most compelling for new backends rather than existing HCL estates.
If you are evaluating Terragrunt because your environment count has outgrown hand-written HCL, it is worth spending an hour on the alternative model before you commit to maintaining a second binary layered on top of Terraform.
Provision Postgres per environment from one declaration, in your own AWS or GCP account. No per-environment HCL.
No. Terragrunt is a thin wrapper that calls the Terraform (or OpenTofu) binary under the hood. It does not provision anything itself. It generates backend config, injects inputs, and orchestrates multiple Terraform modules. You still write Terraform.
Usually not. A single environment with a handful of resources works fine as plain Terraform with a backend block and a variables file. Terragrunt starts paying off once you have three or more environments repeating the same module wiring.
Yes. Current Terragrunt releases default to calling the tofu binary. You can point it at a specific binary with the TERRAGRUNT_TFPATH environment variable or the terraform_binary option. The wrapper is agnostic about which engine runs the plan.
Mainly generated backend configuration, DRY input inheritance across environments, dependency ordering with run-all, and mock outputs for planning. Terraform's own answers (workspaces, Stacks in HCP) overlap partially but do not fully replace Terragrunt's file-tree composition model.
Yes. Terragrunt is maintained by Gruntwork and remains actively developed, including support for OpenTofu and ongoing work on its unit and stack model.