Skip to main content

Terragrunt units and stacks

A unit is one deployable module instance with one state file; a stack file says which units a folder deploys and with which values. Every BuiltForProd infrastructure repository defines each unit type exactly once under units/, describes each account, region or stage with a terragrunt.stack.hcl, and lets Terragrunt generate the deployable tree from the two.

Why it matters in production

A landing zone has many copies of the same thing. The AWS Baseline deploys account-baseline to fourteen accounts, vpc-spoke to four workload accounts and observability-link to seven. Before the Stacks layout, each copy was a checked-in file: 83 unit files in the landing zone, 42 in the web platform, 18 in the ETL platform. A change to one unit meant the same edit in every copy, and the real per-stage differences (domains, node counts, high-availability flags, retention) were buried among identical lines.

With units and stacks, a unit is written once, a stage file contains only what is different about that stage, and adding a workload account or a stage is a three-file addition. Fewer copies means fewer places for a mistake to hide, and a plan that touches every consumer of a definition at once. This is what makes the platform reproducible as code rather than a set of hand-maintained folders.

The vocabulary

WordMeaningWhere it lives
UnitOne deployable module instance, one state file.Generated under .terragrunt-stack/
Unit definitionThe single checked-in definition of a unit type, shared by every account or stage that lists it.units/<unit>/terragrunt.hcl
Stack fileWhich units a folder has, with their values.environments/<...>/<region or global>/terragrunt.stack.hcl
TemplateA reusable group of units a stack file instantiates.stacks/<name>/terragrunt.stack.hcl
IncludeUnits added to several stack files.stacks/includes/<name>.stack.hcl
ValuesThe per-instance data a stack file hands to a definition.values.<key> in a definition, try(values.<key>, default) in a template
Generated treeThe units Terragrunt writes out; git-ignored, never edited.<folder>/.terragrunt-stack/

The AWS Baseline repository looks like this:

Repository layout (acme-aws-platform-baseline)
common.hcl namespace, home_region, domain_name
root.hcl remote state, generated provider, shared locals, hub unit paths
units/<unit>/terragrunt.hcl one definition per unit type (the catalog)
stacks/
plat-account/terragrunt.stack.hcl template: the seven units of a workload account
includes/account-common.stack.hcl include: the units every core account has
environments/
core/network/account.hcl account_name, ou, stage
core/network/global/region.hcl environment = "glob", is_global = true
core/network/global/terragrunt.stack.hcl
core/network/us-west-2/region.hcl aws_region, environment = "usw2"
core/network/us-west-2/network.hcl network feature switches
core/network/us-west-2/terragrunt.stack.hcl
plat/dev/us-west-2/terragrunt.stack.hcl
modules/<resource-type>/ main.tf, variables.tf, outputs.tf
scripts/ guards

The blueprint repositories use the same shape with environments/<stage>/<region>/ and one template each: stacks/webapp-stage (sixteen units) and stacks/etl-stage (six).

How it works

Terragrunt reads the stack file, copies each referenced definition (or each unit of a template) into the folder's .terragrunt-stack/, and evaluates the copies as ordinary units. Each copy includes root.hcl, which finds common.hcl, account.hcl and region.hcl by walking up the folder tree, so the unit knows its namespace, account, stage and region from where it was generated, not from anything written inside it.

Unit definitions

A definition includes the root, names its module, declares its dependencies with complete mock outputs, and builds its inputs from include.root.locals and values.

units/documentdb/terragrunt.hcl (shape of a definition)
include "root" {
path = find_in_parent_folders("root.hcl")
expose = true
}

terraform {
source = "${dirname(find_in_parent_folders("root.hcl"))}/modules/documentdb"
# or: source = "tfr:///terraform-aws-modules/vpc/aws?version=6.7.3"
}

dependency "vpc" {
config_path = "../vpc" # a sibling in the same generated tree
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
mock_outputs = {
vpc_id = "vpc-0mock00000000000"
}
}

inputs = {
name = "${include.root.locals.name_prefix}-docdb"
instance_count = values.docdb_instance_count # required value: fails loudly if missing
tags = include.root.inputs.tags
}

Three rules keep definitions reusable:

  • Never hard-code the region, namespace or domain. Use include.root.locals.aws_region, namespace, domain_name, stage and name_prefix (acme-usw2-dev).
  • Siblings are relative (../vpc). A unit in another account is addressed only through the hub paths root.hcl builds, such as include.root.locals.hub.organizations or hub.ipam, never by an environments/... path typed by hand.
  • The same module in a different role is a separate definition named by role. units/guardduty runs the detector in the security account; units/guardduty-delegation delegates it from the management account. Both generate under the path guardduty, in different accounts.

Stack files

A core account lists its units directly. The network account's regional stack file names the hub VPC, the Transit Gateway, its route tables, the optional firewall and peering units, the private zone and the observability link, and pulls account-baseline in through the include:

environments/core/network/us-west-2/terragrunt.stack.hcl (excerpt)
include "account-common" {
path = find_in_parent_folders("stacks/includes/account-common.stack.hcl")
}

unit "vpc" {
source = find_in_parent_folders("units/vpc-hub")
path = "vpc"
}

unit "transit-gateway" {
source = find_in_parent_folders("units/transit-gateway")
path = "transit-gateway"
}

source names the definition; path is the generated folder and therefore the state key. Sources always resolve from the repository root with find_in_parent_folders, never ../units, because Terragrunt copies a template before evaluating it and a relative path would not exist in the copy.

Templates and values

A workload account does not list units one by one. Its stack file instantiates the plat-account template, which holds the seven units every workload account has:

environments/plat/dev/us-west-2/terragrunt.stack.hcl
stack "plat" {
source = find_in_parent_folders("stacks/plat-account")
path = "."
}

Nothing is set per account here: the account, stage and ipam_key come from account.hcl, region.hcl and root.hcl, and the VPC CIDRs come from the VPC map through the ipam unit.

The blueprint templates take values. A stage file sets only the choices that differ per stage, each tagged # TODO: (must be set) or # @optional: (may be changed), and the template reads them with a default:

environments/prod/us-west-2/terragrunt.stack.hcl (web platform, excerpt)
stack "webapp" {
source = find_in_parent_folders("stacks/webapp-stage")
path = "."

values = {
public_domain = "prod.company.com" # TODO: the stage's public domain
eks_node_min_size = 3 # @optional: 2 replicas per component + redis-ha
argocd_ha = true # @optional: HA needs the 3-node node group
docdb_instance_count = 3 # @optional: HA sizing
}
}
stacks/webapp-stage/terragrunt.stack.hcl (excerpt)
locals {
docdb_instance_count = try(values.docdb_instance_count, 1)
}

unit "documentdb" {
source = find_in_parent_folders("units/documentdb")
path = "blueprint-app/documentdb"
values = { docdb_instance_count = local.docdb_instance_count }
}

The convention is deliberate: a template uses try(values.<key>, default) so a stage may omit a choice; a definition uses a bare values.<key> so a missing required value fails at render time instead of at apply time.

Includes

stacks/includes/account-common.stack.hcl adds account-baseline to every core account. Terragrunt evaluates an include with the including stack's locals in scope, so a stack file overrides a choice by setting a local before the include. The management account sets use_config_slr = true; the public account sets s3_public_access_block = false.

The generated tree

Running terragrunt stack generate, terragrunt stack run plan or terragrunt run --all plan from a folder that holds a stack file writes the units into .terragrunt-stack/. Units listed directly land at .terragrunt-stack/<unit>; a template nests one level deeper, at .terragrunt-stack/.terragrunt-stack/<unit>.

Generated tree for a workload account
environments/plat/dev/us-west-2/
region.hcl
terragrunt.stack.hcl
.terragrunt-stack/ generated, git-ignored
.terragrunt-stack/ one level deeper: a template
account-baseline/terragrunt.hcl
observability/terragrunt.hcl
vpc/terragrunt.hcl
vpc-endpoints/terragrunt.hcl
transit-gateway-attachment/terragrunt.hcl
private-zone-association/terragrunt.hcl
ssm-publish/terragrunt.hcl

The generated files are never edited or committed. To change a unit, change units/, stacks/ or the stack file and regenerate. terragrunt stack clean removes the tree; terragrunt stack output aggregates the folder's outputs.

The state key rule

All state lives in one S3 bucket, acme-use1-root-tfstate, in the management account. root.hcl derives each unit's key from its path relative to the root with the .terragrunt-stack/ segments removed:

root.hcl (remote_state key)
key = "${replace(replace(path_relative_to_include(), "\\", "/"), ".terragrunt-stack/", "")}/terraform.tfstate"

So the dev VPC generated at environments/plat/dev/us-west-2/.terragrunt-stack/.terragrunt-stack/vpc has the key environments/plat/dev/us-west-2/vpc/terraform.tfstate, the same key it would have if the stack file listed it directly. The blueprints prefix their keys with apps/app-blueprint/ and apps/etl-blueprint/. Because the key is the path, renaming a unit's path in a stack file moves its state; that is a planned migration, not a casual edit.

Worked example: how a change propagates

Suppose the definition units/vpc-spoke/terragrunt.hcl gains a new flow-log setting. One edit changes the four workload VPCs at once, because stacks/plat-account lists units/vpc-spoke and all four stage files instantiate that template. The plan workflow renders every generated unit, so the pull request shows four VPC plans. Nothing under environments/ changes, and no state key moves.

Now suppose only prod needs a larger DocumentDB cluster. The edit is one line in environments/prod/us-west-2/terragrunt.stack.hcl: docdb_instance_count = 3. The template passes it to the definition, the definition passes it to the module, and the other stages keep their own values. The per-stage difference is the only thing the stage file contains, which is how environments and promotion stay reviewable.

Guards

scripts/check-stack-layout.py runs in pre-commit and in the plan workflow of every infrastructure repository. It fails on a checked-in terragrunt.hcl under environments/, a region folder without a stack file, a unit definition no stack file references, a relative unit source in a template, and a generated tree inside units/ or stacks/. check-mock-outputs.py requires complete mocks on every dependency, check-required-inputs.py renders every generated unit and checks that each supplies its module's required inputs, and check-module-versions.py keeps provider pins in root.hcl.

Common mistakes

  • Running Terragrunt from the repository root without --working-dir environments. It treats units/ and stacks/ as live configuration and generates into them. Run from a folder that holds a stack file, or pass --working-dir environments.
  • Editing a file under .terragrunt-stack/. The next generate overwrites it. Change the definition, the template or the stack file.
  • Changing a unit's path casually. The path is the state key; changing it makes the unit look for state under an empty key and plan to create everything again.
  • Building a cross-account dependency path by hand. environments/core/root/global/organizations holds only a stack file. Use include.root.locals.hub.organizations.
  • Using dependency.*.outputs in locals. Terragrunt evaluates locals before dependencies. Outputs belong in inputs and generate blocks.
  • Forgetting that a definition is shared. After changing units/<unit>/terragrunt.hcl, plan every account or stage that lists it; the required-inputs guard renders all of them.
  • Using Terragrunt exclude blocks as a feature switch. An excluded unit is skipped by every command, including destroy, so switching a feature off would orphan its resources. The platform keeps module-level toggles (count = 0 when off) so that off means destroyed on the next apply.