← Back to Learning

Terragrunt Basics

Terraform alone has no built-in answer for "the same module, deployed three times, once per environment, with slightly different inputs and its own state file each time." The usual result without help is three near-identical copies of the same configuration, or one configuration with a growing pile of count/for_each hacks bolted on to fake having environments. Terragrunt is a thin wrapper around Terraform that exists specifically to solve that one problem: keep the actual infrastructure code (the module) in exactly one place, and let each environment be a small file that says which module to use and what inputs to give it.

The shape of a real layout

infra/
├── modules/
│   └── webapp/            # the actual resource definitions, written once
│       └── main.tf
└── environments/
    ├── dev/
    │   └── terragrunt.hcl
    └── prod/
        └── terragrunt.hcl

Each environment's terragrunt.hcl is small — it points at the shared module and supplies environment-specific values, nothing else:

# environments/prod/terragrunt.hcl
terraform {
  source = "../../modules/webapp"
}

inputs = {
  instance_count = 5
  environment    = "prod"
}

terragrunt apply run from that directory does exactly what terraform apply would, against that module, with those inputs — Terragrunt generates the actual Terraform invocation, it doesn't replace the Terraform workflow itself.

include: sharing config without copy-pasting it

Backend configuration, provider setup, and common variables usually need to be identical across every environment. Repeating that block in every single terragrunt.hcl defeats the point. A root terragrunt.hcl at the top of the tree defines it once, and each environment includes it:

# root terragrunt.hcl
remote_state {
  backend = "s3"
  config = {
    bucket = "my-terraform-state"
    key    = "${path_relative_to_include()}/terraform.tfstate"
    region = "us-east-1"
  }
}
# environments/prod/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../modules/webapp"
}

inputs = {
  instance_count = 5
}

path_relative_to_include() in the root config automatically becomes each environment's own real path (environments/prod, environments/dev, ...), so every environment gets a correctly separated state file from the exact same shared backend block, with zero per-environment repetition.

dependency: wiring one unit's output into another

Real infrastructure usually isn't one flat set of independent units — a database unit's connection string needs to reach the app unit, a VPC's subnet IDs need to reach everything deployed into it. A dependency block reads another unit's Terraform outputs directly:

# environments/prod/app/terragrunt.hcl
dependency "database" {
  config_path = "../database"
}

inputs = {
  db_connection_string = dependency.database.outputs.connection_string
}

Terragrunt also uses these blocks to figure out apply order automatically across a whole tree (run-all apply applies dependencies before the units that need their outputs) — the same relationship expressed once does double duty as both the wiring and the ordering.

What this buys you

The actual infrastructure logic lives in exactly one module, reviewed and tested in one place. Every environment is a short, readable file that's obviously just "this module, these inputs" — a new environment is a new small file, not a new copy of a thousand lines of resource blocks that will inevitably drift from its siblings over time.