Skip to content

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.

To review, this is what our S3 layout looks like for our state (ignoring the state that we’ve left behind during our refactors):

Terminal window
$ 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.tfstate
dev/iam/tofu.tfstate
dev/lambda/tofu.tfstate
dev/s3/tofu.tfstate
prod/ddb/tofu.tfstate
prod/iam/tofu.tfstate
prod/lambda/tofu.tfstate
prod/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:

Terminal window
dev/.terragrunt-stack/ddb/tofu.tfstate
dev/.terragrunt-stack/iam/tofu.tfstate
dev/.terragrunt-stack/lambda/tofu.tfstate
dev/.terragrunt-stack/s3/tofu.tfstate
prod/.terragrunt-stack/ddb/tofu.tfstate
prod/.terragrunt-stack/iam/tofu.tfstate
prod/.terragrunt-stack/lambda/tofu.tfstate
prod/.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.

Terminal window
# live
$ terragrunt stack generate
16:36:50.794 INFO Generating stack from ./dev/terragrunt.stack.hcl
16:36:50.797 INFO Generating stack from ./prod/terragrunt.stack.hcl
16:36:50.798 INFO Processing unit s3 from ./dev/terragrunt.stack.hcl
16:36:50.798 INFO Processing unit ddb from ./dev/terragrunt.stack.hcl
16:36:50.798 INFO Processing unit lambda from ./dev/terragrunt.stack.hcl
16:36:50.798 INFO Processing unit iam from ./dev/terragrunt.stack.hcl
16:36:50.798 INFO Processing unit lambda from ./prod/terragrunt.stack.hcl
16:36:50.798 INFO Processing unit iam from ./prod/terragrunt.stack.hcl
16:36:50.798 INFO Processing unit ddb from ./prod/terragrunt.stack.hcl
16: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.

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"
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"
}
}
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"
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

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

To migrate state from the old unit paths to the new paths, we can run the following:

Terminal window
# live
# Migrate dev state
cd dev/ddb
terragrunt state pull > /tmp/tofu.tfstate
cd ../.terragrunt-stack/ddb
terragrunt state push /tmp/tofu.tfstate
cd ../../s3
terragrunt state pull > /tmp/tofu.tfstate
cd ../.terragrunt-stack/s3
terragrunt state push /tmp/tofu.tfstate
cd ../../iam
terragrunt state pull > /tmp/tofu.tfstate
cd ../.terragrunt-stack/iam
terragrunt state push /tmp/tofu.tfstate
cd ../../lambda
terragrunt state pull > /tmp/tofu.tfstate
cd ../.terragrunt-stack/lambda
terragrunt state push /tmp/tofu.tfstate
# Migrate prod state
cd ../../../prod/ddb
terragrunt state pull > /tmp/tofu.tfstate
cd ../.terragrunt-stack/ddb
terragrunt state push /tmp/tofu.tfstate
cd ../../s3
terragrunt state pull > /tmp/tofu.tfstate
cd ../.terragrunt-stack/s3
terragrunt state push /tmp/tofu.tfstate
cd ../../iam
terragrunt state pull > /tmp/tofu.tfstate
cd ../.terragrunt-stack/iam
terragrunt state push /tmp/tofu.tfstate
cd ../../lambda
terragrunt state pull > /tmp/tofu.tfstate
cd ../.terragrunt-stack/lambda
terragrunt state push /tmp/tofu.tfstate

We can now remove the .gitignore files, and prove to ourselves that state has migrated successfully!

Terminal window
# live
rm -f ./***/.gitignore
terragrunt run --all plan
# No changes!

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-managed terragrunt.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.

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.