Why Terragrunt exists
Terraform is excellent for defining infrastructure as code. But once you have more than one environment — dev, staging, production — you run into the same structural problem every team hits: how do you avoid duplicating your Terraform code across environments while keeping each environment independently configurable?
The most common answer people reach for is workspaces. Workspaces don't solve it — they share a single backend configuration and make environment isolation harder, not easier. The correct answer is separate state per environment, and that's where Terragrunt comes in.
Terragrunt is a thin wrapper around Terraform that adds:
- DRY backend configuration (write once, inherit everywhere)
- Dependency graph between Terraform modules
- Before/after hooks for automation
run-allcommands to apply an entire environment at once
It doesn't replace Terraform — it orchestrates it.
The problem Terragrunt solves
Without Terragrunt, a typical multi-environment Terraform layout looks like this:
infrastructure/
dev/
main.tf
backend.tf # dev-specific backend config
terraform.tfvars
staging/
main.tf # copy of dev/main.tf with different values
backend.tf # copy with different key
terraform.tfvars
production/
main.tf # copy again
backend.tf # copy again
terraform.tfvars
Every environment has nearly identical main.tf files. When you add a new module, you update it in three places. When you rename a variable, same thing. This is the DRY problem.
Terragrunt solves it by introducing terragrunt.hcl files that separate configuration from code:
infrastructure/
terragrunt.hcl # root config: backend, provider, common inputs
modules/ # actual Terraform code lives here
vpc/
eks/
rds/
dev/
terragrunt.hcl # inherits root config, points to module
vpc/
terragrunt.hcl # dev VPC config
eks/
terragrunt.hcl # dev EKS config
production/
terragrunt.hcl
vpc/
terragrunt.hcl
eks/
terragrunt.hcl
The Terraform module code exists once. The Terragrunt hcl files at each environment level override only what differs.
Root terragrunt.hcl: backend generation
The root terragrunt.hcl defines the remote state backend once. Every environment inherits it, with the state path automatically derived from the directory structure:
# infrastructure/terragrunt.hcl
locals {
account_id = get_aws_account_id()
region = "us-east-1"
# Extract environment from the directory path
# e.g. infrastructure/production/eks → "production"
env = element(split("/", path_relative_to_include()), 0)
}
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "my-company-terraform-state-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = local.region
encrypt = true
dynamodb_table = "terraform-state-locks"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.region}"
default_tags {
tags = {
ManagedBy = "Terragrunt"
Environment = "${local.env}"
}
}
}
EOF
}
Every environment automatically gets a backend config. The S3 key is derived from the path — dev/eks/terraform.tfstate, production/vpc/terraform.tfstate — without writing a single backend block in the environment configs.
Environment-level config
Each environment-level terragrunt.hcl inherits the root config and optionally sets environment-wide inputs:
# infrastructure/production/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
inputs = {
environment = "production"
aws_account_id = "987654321098"
}
Module-level config
Each individual module directory has its own terragrunt.hcl that points to the Terraform module and provides the inputs specific to that environment:
# infrastructure/production/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../../modules//vpc"
}
inputs = {
name = "production"
cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
}
# infrastructure/production/eks/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../../modules//eks"
}
# Cross-module dependency: EKS needs the VPC ID from the vpc module
dependency "vpc" {
config_path = "../vpc"
}
inputs = {
cluster_name = "production"
cluster_version = "1.30"
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.private_subnet_ids
node_groups = {
general = {
instance_types = ["m5.xlarge"]
desired_size = 3
min_size = 2
max_size = 10
}
}
}
The dependency block is one of Terragrunt's most useful features. It reads the outputs of another module's state and passes them as inputs — no manual copying of VPC IDs or subnet lists across modules.
Applying an entire environment
Without Terragrunt, applying an environment means running terraform apply in each module directory, in the right order. With Terragrunt's run-all, you apply everything at once:
# Apply all modules in production, respecting dependency order
terragrunt run-all apply --terragrunt-working-dir infrastructure/production
# Plan everything without applying
terragrunt run-all plan --terragrunt-working-dir infrastructure/production
# Target a single module
cd infrastructure/production/eks
terragrunt apply
run-all walks the dependency graph — it applies vpc before eks because eks depends on vpc. No manual orchestration required.
Before/after hooks
Terragrunt hooks let you run scripts before or after Terraform commands. Common uses: formatting, validation, notifications.
terraform {
source = "../../../modules//eks"
before_hook "validate" {
commands = ["apply", "plan"]
execute = ["terraform", "validate"]
}
after_hook "notify" {
commands = ["apply"]
execute = ["bash", "-c", "echo 'EKS apply complete in ${get_env("ENV")}' | slack-notify"]
run_on_error = false
}
}
CI/CD with Terragrunt
In GitHub Actions, you can scope a workflow to only apply changed modules:
name: Terraform
on:
push:
branches: [main]
paths:
- 'infrastructure/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.8.0
- name: Setup Terragrunt
run: |
wget -q -O /usr/local/bin/terragrunt \
https://github.com/gruntwork-io/terragrunt/releases/latest/download/terragrunt_linux_amd64
chmod +x /usr/local/bin/terragrunt
- name: Terragrunt plan (staging)
if: github.event_name == 'pull_request'
run: terragrunt run-all plan --terragrunt-working-dir infrastructure/staging
- name: Terragrunt apply (staging)
if: github.ref == 'refs/heads/main'
run: terragrunt run-all apply --auto-approve --terragrunt-working-dir infrastructure/staging
When you don't need Terragrunt
Terragrunt adds real complexity. If you're in any of these situations, vanilla Terraform is probably enough:
- Single environment — one AWS account, one deployment target. Just use modules.
- Fewer than 5 Terraform modules — the dependency wiring isn't worth it.
- Team new to Terraform — master Terraform before adding a wrapper.
- Heavy Terraform Cloud / HCP Terraform users — workspace-level variables and run triggers partially cover what Terragrunt does.
The signal to reach for Terragrunt is when you catch yourself copy-pasting backend blocks across environment directories, or when applying an environment requires you to remember the right order for a dozen terraform apply commands.
Terraform vs Terragrunt: what each owns
| Concern | Terraform | Terragrunt |
|---|---|---|
| Resource definitions | ✓ | — |
| Provider config | Generated by Terragrunt | ✓ |
| Backend config | Generated by Terragrunt | ✓ |
| Module reuse | ✓ (modules) | ✓ (source pointing) |
| Cross-module dependencies | Via outputs/data sources | ✓ (dependency block) |
| Environment orchestration | Manual | ✓ (run-all) |
| State isolation per module | Manual | ✓ (path-derived keys) |
Terraform owns the infrastructure definition. Terragrunt owns the orchestration and configuration.
Summary
Terragrunt is worth adopting once you have multiple environments and feel the pain of duplicated backend configs and manual apply ordering. Its three core wins are: single backend definition inherited everywhere, dependency blocks that wire module outputs as inputs, and run-all for environment-level applies.
The Terraform code itself stays unchanged — Terragrunt wraps it, doesn't modify it. You can always eject back to vanilla Terraform by generating the backend and provider files that Terragrunt was creating for you.