Skip to content

Step 7: Taking advantage of Terragrunt Stacks

Up until relatively recently in the history of Terragrunt, the proliferation of terragrunt.hcl files was the trade-off platform engineers had to accept for state segmentation. Luckily, with the advent of Terragrunt Stacks, that’s not the case anymore. Collections of units, like the ones you created in the last step, can be generated on-demand using terragrunt.stack.hcl files. This saves you from having to duplicate terragrunt.hcl files across your codebase.

In this step, you will migrate to this modern pattern. You’ll start by persisting your unit definitions in your catalog. Then, you’ll replace the numerous terragrunt.hcl files in your live directory with a single terragrunt.stack.hcl file in each environment. To handle the slight configuration differences between dev and prod, you’ll use Terragrunt’s values attributes, which allow you to parameterize your reusable unit definitions.

Let’s start migrating to Terragrunt Stacks by persisting unit definitions in the catalog.

Terminal window
mkdir -p catalog/units/{ddb, iam, lambda, s3}
cp live/dev/ddb/{terragrunt.hcl, .terraform.lock.hcl} catalog/units/ddb/
cp live/dev/iam/{terragrunt.hcl, .terraform.lock.hcl} catalog/units/iam/
cp live/dev/lambda/{terragrunt.hcl, .terraform.lock.hcl} catalog/units/lambda/
cp live/dev/s3/{terragrunt.hcl, .terraform.lock.hcl} catalog/units/s3/

You might remember that the unit configurations differed very slightly between dev and prod. Luckily, Terragrunt has special tooling to handle that via the usage of values variables.

catalog/units/ddb/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "${find_in_parent_folders("catalog/modules")}//ddb"
}
inputs = {
name = values.name
}

By specifying values.name there, we’re allowing values to be used in our unit configurations from terragrunt.stack.hcl files. You’ll see how these values are set later in this step.

catalog/units/iam/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "${find_in_parent_folders("catalog/modules")}//iam"
}
dependency "s3" {
config_path = values.s3_path
mock_outputs_allowed_terraform_commands = ["plan", "state"]
mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs = {
arn = "arn:aws:s3:::mock-bucket-name"
}
}
dependency "ddb" {
config_path = values.ddb_path
mock_outputs_allowed_terraform_commands = ["plan", "state"]
mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs = {
arn = "arn:aws:dynamodb:us-east-1:123456789012:table/mock-table-name"
}
}
inputs = {
name = values.name
aws_region = values.aws_region
s3_bucket_arn = dependency.s3.outputs.arn
dynamodb_table_arn = dependency.ddb.outputs.arn
}
catalog/units/lambda/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "${find_in_parent_folders("catalog/modules")}//lambda"
}
dependency "s3" {
config_path = "../s3"
mock_outputs_allowed_terraform_commands = ["plan", "state"]
mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs = {
name = "mock-bucket-name"
}
}
dependency "ddb" {
config_path = "../ddb"
mock_outputs_allowed_terraform_commands = ["plan", "state"]
mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs = {
name = "mock-table-name"
}
}
dependency "iam" {
config_path = "../iam"
mock_outputs_allowed_terraform_commands = ["plan", "state"]
mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs = {
arn = "arn:aws:iam::123456789012:role/mock-role-name"
}
}
inputs = {
name = values.name
aws_region = values.aws_region
s3_bucket_name = dependency.s3.outputs.name
dynamodb_table_name = dependency.ddb.outputs.name
lambda_role_arn = dependency.iam.outputs.arn
lambda_zip_file = "${get_repo_root()}/dist/best-cat.zip"
}
catalog/units/s3/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "${find_in_parent_folders("catalog/modules")}//s3"
}
inputs = {
name = values.name
}

Now we can replace the terragrunt.hcl files in live with a single terragrunt.stack.hcl file in each environment to generate them on-demand using unit blocks. By default, units generated by Terragrunt are generated into .terragrunt-stack directories. We opt out of that by setting no_dot_terragrunt_stack to true.

live/dev/terragrunt.stack.hcl
locals {
name = "best-cat-2025-07-31-01-dev"
aws_region = "us-east-1"
units_path = find_in_parent_folders("catalog/units")
}
unit "ddb" {
source = "${local.units_path}/ddb"
path = "ddb"
no_dot_terragrunt_stack = true
values = {
name = local.name
}
}
unit "s3" {
source = "${local.units_path}/s3"
path = "s3"
no_dot_terragrunt_stack = true
values = {
name = local.name
}
}
unit "iam" {
source = "${local.units_path}/iam"
path = "iam"
no_dot_terragrunt_stack = true
values = {
name = local.name
aws_region = local.aws_region
s3_path = "../s3"
ddb_path = "../ddb"
}
}
unit "lambda" {
source = "${local.units_path}/lambda"
path = "lambda"
no_dot_terragrunt_stack = true
values = {
name = local.name
aws_region = local.aws_region
s3_path = "../s3"
ddb_path = "../ddb"
iam_path = "../iam"
}
}

We’ll also add this .gitignore file to avoid recommitting the generated files in our repository, as they’ll be regenerated whenever we need them. We’ll see how we can remove the need for this in a future step.

live/dev/.gitignore
*
!.gitignore
!terragrunt.stack.hcl

Now that we can generate these unit configurations on demand, we can remove the copies that we created manually!

live/dev
rm -rf .terraform.lock.hcl ddb iam lambda s3
terragrunt run --all plan

All that’s left now is to repeat the same thing for prod.

live/prod/terragrunt.stack.hcl
locals {
name = "best-cat-2025-07-31-01"
aws_region = "us-east-1"
units_path = find_in_parent_folders("catalog/units")
}
unit "ddb" {
source = "${local.units_path}/ddb"
path = "ddb"
no_dot_terragrunt_stack = true
values = {
name = local.name
}
}
unit "s3" {
source = "${local.units_path}/s3"
path = "s3"
no_dot_terragrunt_stack = true
values = {
name = local.name
}
}
unit "iam" {
source = "${local.units_path}/iam"
path = "iam"
no_dot_terragrunt_stack = true
values = {
name = local.name
aws_region = local.aws_region
s3_path = "../s3"
ddb_path = "../ddb"
}
}
unit "lambda" {
source = "${local.units_path}/lambda"
path = "lambda"
no_dot_terragrunt_stack = true
values = {
name = local.name
aws_region = local.aws_region
s3_path = "../s3"
ddb_path = "../ddb"
iam_path = "../iam"
}
}
live/prod/.gitignore
*
!.gitignore
!terragrunt.stack.hcl
live/prod
rm -rf .terraform.lock.hcl ddb iam lambda s3
terragrunt run --all plan

If you clean out the .gitignore’ed files and take a look at the file tree, you should see that your live file count has shrunk down again!

Terminal window
# live
$ rm -rf ./***/ddb ./***/iam ./***/lambda ./***/s3
  • Directorylive
    • Directorydev
      • terragrunt.stack.hcl
    • Directoryprod
      • terragrunt.stack.hcl
    • root.hcl

You’ve now adopted one of Terragrunt’s most advanced features, Terragrunt Stacks, to achieve an exceptionally clean and DRY (Don’t Repeat Yourself) infrastructure codebase. By generating your component configurations on the fly from a central catalog, you’ve eliminated the last major source of boilerplate in your Terragrunt configurations. However, this abstraction comes with its own set of trade-offs.

  • Maximum Reusability and Deduplication: This is the most significant benefit of using Terragrunt Stacks. Instead of having multiple terragrunt.hcl files scattered across each environment’s subdirectories, you now have a single, reusable unit definition for each component in your catalog. Adding a new environment is as simple as creating a new terragrunt.stack.hcl and defining its unique inputs.
  • Simplified live Directory: Your live directory is now incredibly lean and easy to navigate. Each environment is represented by a single terragrunt.stack.hcl file, which serves as a clear manifest of all the components that make up that environment. This is a similar layout to that which was achieved in step 5, but we’ve gained the ability to retain state segmentation and operate granularly.
  • Centralized Configuration Catalog: If you need to update the configuration for a component across all environments (e.g., add a new dependency or change a mock_output), you only need to edit the corresponding file in catalog/units. This drastically reduces the chance of configuration drift and makes maintenance much easier.
  • Increased Abstraction: The biggest trade-off is the added layer of indirection. Engineers no longer have all the configuration for their Terragrunt units committed to their repository. If they want to read through their configurations, they need to generate the stack, or read the contents stored in their catalog.
  • Steeper Learning Curve: The concepts of terragrunt.stack.hcl, unit blocks, and the values attribute are powerful but are also more advanced Terragrunt features. Onboarding new team members may require more time to explain this higher level of abstraction compared to the more explicit file-based approach from the previous step.

You’ve conquered the final major source of Terragrunt boilerplate! In this step, you adopted one of Terragrunt’s most powerful features: Terragrunt Stacks.

By centralizing your generic unit configurations into the catalog, you were able to replace the numerous terragrunt.hcl files in each environment with a single, clean terragrunt.stack.hcl file. The core concept you mastered was using the values object to parameterize these reusable units, allowing you to define a component’s configuration once and deploy it many times with environment-specific values.

However, there’s one piece of technical debt left from our migration: the state paths in your S3 backend don’t yet align with Terragrunt’s default conventions, requiring those annoying .gitignore files. In the final step, you’ll perform one last set of state migrations to finalize your layout, fully mastering this guide.