HomeBlogUnderstanding Terraform and Terragrunt
TerraformTerragruntIaCAWS

Understanding Terraform and Terragrunt

June 6, 2026·13 min read·Omphora Engineering

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-all commands 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.

Not sure where to start?
Let's talk.

One conversation, no commitment. We listen to what your team is struggling with and give you an honest picture of what needs to change — and what doesn't.

  • What's slowing down your team's deployment process
  • Where your cloud spend is going — and what's being wasted
  • Security vulnerabilities in your current setup
  • Reliability gaps that could cause downtime
  • Blind spots in your monitoring and alerting
Available for new projectsResponse within 1 business dayNo long-term commitment required
your-infra ~ after-omphora
$ terraform apply
✓ 23 resources. Apply complete in 4m 12s
$ kubectl get nodes
NAME STATUS ROLES AGE
ip-10-0-1 Ready worker 2d
ip-10-0-2 Ready worker 2d
ip-10-0-3 Ready worker 2d
$ argocd app list
production Synced Healthy
staging Synced Healthy
$ # Commit → production: 3m 42s
✓ Zero downtime · p99: 82ms · cost ↓ 38%
$ # Example output — results vary by workload.
3m 42s
Deploy time
38%
Cost saved
99.9%
Uptime