Step 8: Refactoring state with Terragrunt Stacks
You’ve just completed a major refactor using Terragrunt Stacks.
However, there’s one final piece of technical debt remaining to complete this guide. To make the transition in the previous step smoother, we used the no_dot_terragrunt_stack
attribute, which generated the unit configurations directly into directories like dev/s3
and prod/lambda
.
While this worked perfectly fine for our migration, and is a recommended first step to adopting Terragrunt Stacks, it’s not the standard configuration you would arrive at if you wrote the configurations by hand. By default, Terragrunt generates unit configurations into a hidden .terragrunt-stack
directory within each environment. This keeps your generated code is neatly tucked away and easily ignored by version control. Our current setup requires .gitignore
files in each stack directory to prevent committing this generated code.
In this final step, you will perform one last state migration to align your project with Terragrunt’s best practices. You will remove the no_dot_terragrunt_stack
attribute and move your state to match the default, conventional directory structure.
Tutorial
Section titled “Tutorial”To review, this is what our S3 layout looks like for our state (ignoring the state that we’ve left behind during our refactors):
$ aws s3 ls --recursive s3://[your-state-bucket] | awk '{print $4}' | rg -v '^tofu.tfstate$' | rg -v '^dev/tofu.tfstate$' | rg -v '^prod/tofu.tfstate$'dev/ddb/tofu.tfstatedev/iam/tofu.tfstatedev/lambda/tofu.tfstatedev/s3/tofu.tfstateprod/ddb/tofu.tfstateprod/iam/tofu.tfstateprod/lambda/tofu.tfstateprod/s3/tofu.tfstate
What we’d like our state keys to look like is the following, which is how it would look if we provisioned our stack without usage of no_dot_terragrunt_stack
from the beginning:
dev/.terragrunt-stack/ddb/tofu.tfstatedev/.terragrunt-stack/iam/tofu.tfstatedev/.terragrunt-stack/lambda/tofu.tfstatedev/.terragrunt-stack/s3/tofu.tfstateprod/.terragrunt-stack/ddb/tofu.tfstateprod/.terragrunt-stack/iam/tofu.tfstateprod/.terragrunt-stack/lambda/tofu.tfstateprod/.terragrunt-stack/s3/tofu.tfstate
Given that there’s a close relationship between filesystem layout and backend state keys, we can achieve this by having our units generated into the default .terragrunt-stack
directories instead of being generated directly adjacent to terragrunt.stack.hcl
files.
What we’ll want to do is perform state migration while having both unit layouts generated locally. If you remember earlier steps, the way to that this is to use the state pull
and state push
commands.
First, let’s make sure we have our stack generated as-is without removing the no_dot_terragrunt_stack
attribute.
# live
$ terragrunt stack generate16:36:50.794 INFO Generating stack from ./dev/terragrunt.stack.hcl16:36:50.797 INFO Generating stack from ./prod/terragrunt.stack.hcl16:36:50.798 INFO Processing unit s3 from ./dev/terragrunt.stack.hcl16:36:50.798 INFO Processing unit ddb from ./dev/terragrunt.stack.hcl16:36:50.798 INFO Processing unit lambda from ./dev/terragrunt.stack.hcl16:36:50.798 INFO Processing unit iam from ./dev/terragrunt.stack.hcl16:36:50.798 INFO Processing unit lambda from ./prod/terragrunt.stack.hcl16:36:50.798 INFO Processing unit iam from ./prod/terragrunt.stack.hcl16:36:50.798 INFO Processing unit ddb from ./prod/terragrunt.stack.hcl16:36:50.798 INFO Processing unit s3 from ./prod/terragrunt.stack.hcl
Now let’s edit our terragrunt.stack.hcl
files to remove the no_dot_terragrunt_stack
attribute. This will generate units into the desired final directory structure.
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"
values = { name = local.name }}
unit "s3" { source = "${local.units_path}/s3" path = "s3"
values = { name = local.name }}
unit "iam" { source = "${local.units_path}/iam" path = "iam"
values = { name = local.name
aws_region = local.aws_region
s3_path = "../s3" ddb_path = "../ddb" }}
unit "lambda" { source = "${local.units_path}/lambda" path = "lambda"
values = { name = local.name
aws_region = local.aws_region
s3_path = "../s3" ddb_path = "../ddb" iam_path = "../iam" }}
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"
values = { name = local.name }}
unit "s3" { source = "${local.units_path}/s3" path = "s3"
values = { name = local.name }}
unit "iam" { source = "${local.units_path}/iam" path = "iam"
values = { name = local.name
aws_region = local.aws_region
s3_path = "../s3" ddb_path = "../ddb" }}
unit "lambda" { source = "${local.units_path}/lambda" path = "lambda"
values = { name = local.name
aws_region = local.aws_region
s3_path = "../s3" ddb_path = "../ddb" iam_path = "../iam" }}
Now let’s generate again to get that generated .terragrunt-stack
directory.
# live
terragrunt stack generate
Project Layout Check-in
Section titled “Project Layout Check-in”Should see a layout like the following now, with both a stack generated within .terragrunt-stack
and one generated outside of it:
Directorylive
Directorydev
Directory.terragrunt-stack
Directoryddb
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directoryiam
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directorylambda
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directorys3
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directoryddb
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directoryiam
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directorylambda
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directorys3
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
- terragrunt.stack.hcl
Directoryprod
Directory.terragrunt-stack
Directoryddb
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directoryiam
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directorylambda
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directorys3
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directoryddb
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directoryiam
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directorylambda
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
Directorys3
- .terraform.lock.hcl
- .terragrunt-stack-manifest
- terragrunt.hcl
- terragrunt.values.hcl
- terragrunt.stack.hcl
Applying Updates
Section titled “Applying Updates”To migrate state from the old unit paths to the new paths, we can run the following:
# live
# Migrate dev statecd dev/ddbterragrunt state pull > /tmp/tofu.tfstatecd ../.terragrunt-stack/ddbterragrunt state push /tmp/tofu.tfstatecd ../../s3terragrunt state pull > /tmp/tofu.tfstatecd ../.terragrunt-stack/s3terragrunt state push /tmp/tofu.tfstatecd ../../iamterragrunt state pull > /tmp/tofu.tfstatecd ../.terragrunt-stack/iamterragrunt state push /tmp/tofu.tfstatecd ../../lambdaterragrunt state pull > /tmp/tofu.tfstatecd ../.terragrunt-stack/lambdaterragrunt state push /tmp/tofu.tfstate
# Migrate prod statecd ../../../prod/ddbterragrunt state pull > /tmp/tofu.tfstatecd ../.terragrunt-stack/ddbterragrunt state push /tmp/tofu.tfstatecd ../../s3terragrunt state pull > /tmp/tofu.tfstatecd ../.terragrunt-stack/s3terragrunt state push /tmp/tofu.tfstatecd ../../iamterragrunt state pull > /tmp/tofu.tfstatecd ../.terragrunt-stack/iamterragrunt state push /tmp/tofu.tfstatecd ../../lambdaterragrunt state pull > /tmp/tofu.tfstatecd ../.terragrunt-stack/lambdaterragrunt state push /tmp/tofu.tfstate
We can now remove the .gitignore
files, and prove to ourselves that state has migrated successfully!
# live
rm -f ./***/.gitignoreterragrunt run --all plan
# No changes!
Trade-offs
Section titled “Trade-offs”This final refactor brings your project into alignment with Terragrunt’s standard conventions, but there are some minor trade-offs to consider.
- Cleaner Working Directory: The most significant advantage is the cleanliness of your
live
directory. All generated code now resides in a hidden.terragrunt-stack
directory, leaving your environment folders (e.g.,live/dev
) containing only your manually-managedterragrunt.stack.hcl
file. - Simplified Version Control: You can now remove the environment-specific
.gitignore
files. A single, global entry to ignore.terragrunt-stack
and.terragrunt-cache
is all that’s needed, making your version control rules simpler and more reliable.
- State Migration: The primary cost is the one-time effort of performing the state migration. While powerful, any direct state manipulation requires careful execution to avoid errors. This refactor is an investment of time and attention to detail.
- Tooling Requirements: If you currently use a CI/CD tool that supports Terragrunt, it has to have built-in awareness of how Terragrunt Stack generation, like Gruntwork Pipelines. CI/CD tools that have been around for a long while might not prioritize handling stack generation, and lack support as a consequence. Placing all generated units in a
.gitignore
file, CI/CD tools might not be able to track when units change, and make selective changes to IaC.
Wrap Up
Section titled “Wrap Up”This final step was about aligning with Terragrunt’s standard conventions. By removing the no_dot_terragrunt_stack
attribute, you enabled Terragrunt’s default behavior of generating code into a hidden .terragrunt-stack
directory.
This required one last, careful state migration. You used terragrunt state pull
to download state from old unit keys and terragrunt state push
to the new, conventional backend keys that matched the updated directory structure from stack generation. Your project is now not only easy to manage but also immediately familiar to any engineer experienced with Terragrunt, featuring a state backend structure aligned with your filesystem.