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.
Tutorial
Section titled “Tutorial”Let’s start migrating to Terragrunt Stacks by persisting unit definitions in the catalog
.
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.
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.
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}
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"}
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
.
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.
*!.gitignore!terragrunt.stack.hcl
Now that we can generate these unit configurations on demand, we can remove the copies that we created manually!
rm -rf .terraform.lock.hcl ddb iam lambda s3terragrunt run --all plan
All that’s left now is to repeat the same thing for prod.
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" }}
*!.gitignore!terragrunt.stack.hcl
rm -rf .terraform.lock.hcl ddb iam lambda s3terragrunt run --all plan
Project Layout Check-in
Section titled “Project Layout Check-in”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!
# live
$ rm -rf ./***/ddb ./***/iam ./***/lambda ./***/s3
Directorylive
Directorydev
- terragrunt.stack.hcl
Directoryprod
- terragrunt.stack.hcl
- root.hcl
Trade-offs
Section titled “Trade-offs”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 yourcatalog
. Adding a new environment is as simple as creating a newterragrunt.stack.hcl
and defining its unique inputs. - Simplified
live
Directory: Yourlive
directory is now incredibly lean and easy to navigate. Each environment is represented by a singleterragrunt.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 incatalog/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 thevalues
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.
Wrap Up
Section titled “Wrap Up”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.