Terraform Lesson 21 of 57

Terragrunt Stacks, In Depth: Units, Stacks, values & Generating Infrastructure from a Blueprint

You have built the live tree by hand — a terragrunt.hcl per component, wired with dependency, run with run --all — and it works. Then the platform team is asked for the fifth near-identical environment, or a per-tenant copy of the same VPC-plus-RDS-plus-app bundle, and you notice the duplication has simply moved up a level: instead of copy-pasting backend blocks, you are now copy-pasting whole directories of units. Every new environment means hand-stamping a tree of terragrunt.hcl files that differ only in a CIDR, a size, and a name. That is the wall Terragrunt Stacks were built to remove. A Stack is a blueprint: you describe a bundle of units once, in a terragrunt.stack.hcl, parametrise it with values, and terragrunt stack generate materialises the whole tree of real units for you — per environment, per region, per tenant — from that single declaration.

This lesson is an exhaustive treatment of that feature and nothing else. The two companion lessons stay deliberately in their lanes: Terragrunt Configuration, In Depth is the field-by-field reference for the terragrunt.hcl config language (every block and attribute, the function catalogue, hooks, the errors model), and Scaling Terragrunt Monorepos owns the orchestration of a hand-built tree (the DAG, run --all/run --graph, --filter-affected, parallelism, CI). Neither goes deep on Stacks, because Stacks are a distinct layer that sits above both: a generator that produces the very units those lessons configure and orchestrate. Here we enumerate the whole Stacks surface — units vs stacks, the unit and stack blocks attribute by attribute, the values/values.* mechanism that makes a blueprint reusable, the .terragrunt-stack/ working tree, and the stack generate/run/output commands — and, because the name causes real confusion, we draw a sharp line between Terragrunt Stacks and HashiCorp’s entirely separate Terraform/OpenTofu Stacks. Everything targets a current Terragrunt release (2026, on the road to 1.0), Terraform 1.9+/OpenTofu, and works identically against either engine. Stacks are a relatively new feature still stabilising, so where behaviour is experiment-gated or still settling, the lesson says so.

Learning objectives

By the end of this lesson you will be able to:

Prerequisites

This is an advanced lesson that assumes the working Terragrunt knowledge from Terragrunt Fundamentals: DRY Configurations, Remote State & Dependencies — what a unit is, the live/modules split, that remote_state/generate write .tf files Terragrunt hands to the engine, and the basic shape of include and dependency. It builds directly on two deep references you should have to hand. For the config language — every attribute of terraform, remote_state, generate, include, dependency, the function catalogue, hooks, and the errors block — see Terragrunt Configuration, In Depth; this lesson uses those blocks freely without re-explaining them. For the orchestration of a tree of units — how the DAG is built, how run --all/run --graph traverse it, --filter-affected, parallelism, and CI — see Scaling Terragrunt Monorepos with Dependency Graphs and run-all; Stacks generate the tree that lesson then runs, so the two compose. You also want solid Terraform: HCL syntax, backends and state locking, and module sourcing/versioning. In the KloudVin Terraform & DevOps Zero-to-Hero course this sits in the Terragrunt module as the modern-architecture deep-dive between the config reference and the multi-environment capstone. You need only a free local toolchain — a current terragrunt plus terraform or tofu — and the hands-on lab runs entirely on the local backend, so it costs nothing and touches no cloud.

Core concepts: unit, stack, and “generate then run”

Three ideas carry the whole feature. Hold them before the attribute tables, because every option below only makes sense against them.

1. A unit is one module instantiation; a stack is a recipe for many. A unit is the atom you already know: a directory with a terragrunt.hcl that points a single module at a single backend key — Terragrunt’s node in the DAG. A stack is a blueprint: a terragrunt.stack.hcl that lists the units (and nested stacks) that make up a deployable bundle, where each comes from, where it should be placed, and what values customise it. The stack file provisions nothing itself; it is a generator. The mental upgrade from the classic model is: instead of hand-writing twelve unit directories for an environment, you declare the bundle once and let Terragrunt stamp the twelve directories.

2. generate materialises real units into .terragrunt-stack/. When you run terragrunt stack generate (or any run against a stack file, which generates first), Terragrunt reads the terragrunt.stack.hcl, fetches each unit’s source, copies it to the path you named inside a .terragrunt-stack/ directory next to the stack file, and writes the unit’s values so the generated terragrunt.hcl can read them. The result is an ordinary tree of units — the same kind of tree you would have built by hand — that the normal DAG and run --all machinery then operate on. .terragrunt-stack/ is generated output: you .gitignore it, exactly as you do .terragrunt-cache/.

3. values is the parameter channel into a generated unit. A blueprint is only reusable if each instantiation can differ. values is a map you set on a unit/stack block; inside the generated unit’s config you read it back as values.<key>. This is a new, distinct channel — not inputs (which feeds the module’s variables), not locals, and not dependency outputs. values flows stack file → generated unit config; what the unit then does with those values (turn them into inputs, derive locals, choose a source ref) is up to the unit’s own terragrunt.hcl.

A one-line map of the new vocabulary before we take each piece in turn:

Term What it is
Unit A directory with a terragrunt.hcl — one module instantiation, one DAG node, one state key.
Stack A terragrunt.stack.hcl — a blueprint listing unit/stack blocks that generate units.
unit block In a stack file: one generated unit (source, path, values).
stack block In a stack file: a generated nested stack (a stack-of-stacks).
values A map passed from the stack file into a generated unit, read back as values.*.
.terragrunt-stack/ The generated working tree the blueprint stamps out (git-ignored).
stack generate / run / output The CLI verbs that generate the tree and operate on it.

Units vs Stacks: the model, side by side

The clearest way to internalise Stacks is to see the same outcome built both ways.

The classic, hand-built way. You create one directory per component and write a terragrunt.hcl in each, repeating the structure for every environment:

live/
  prod/
    vpc/  terragrunt.hcl       # hand-written
    rds/  terragrunt.hcl       # hand-written
    app/  terragrunt.hcl       # hand-written
  staging/
    vpc/  terragrunt.hcl       # hand-written copy, different CIDR
    rds/  terragrunt.hcl       # hand-written copy, smaller size
    app/  terragrunt.hcl       # hand-written copy, fewer replicas

Every new environment is a new hand-stamped subtree. The bundle’s shape (vpc → rds → app, wired by dependency) is duplicated in each environment, and it drifts: someone tweaks prod/app and forgets staging/app.

The Stacks way. You describe the bundle once and instantiate it:

units/                          # the reusable unit templates (each a terragrunt.hcl + maybe values)
  vpc/
  rds/
  app/
live/
  prod/
    terragrunt.stack.hcl        # "stamp vpc+rds+app here, prod values"
  staging/
    terragrunt.stack.hcl        # "stamp vpc+rds+app here, staging values"

terragrunt stack generate in live/prod reads its terragrunt.stack.hcl and produces live/prod/.terragrunt-stack/{vpc,rds,app}/ — a real tree of units — from the templates in units/, customised by the prod values. The bundle’s shape now lives in one place; a change to the blueprint or a unit template propagates to every environment on the next generate. That is the headline: Stacks turn “copy a directory of units” into “instantiate a blueprint.”

The relationship to run --all is precise. run --all did not create units — it discovered hand-built ones and ran them in DAG order. Stacks add the creation step: generate the units from a blueprint, then the same DAG/run --all machinery runs them. So Stacks do not replace run --all; they replace the manual authoring of the tree that run --all consumes. This is why Stacks are called “the evolution beyond run-all”: run-all solved running a tree; Stacks solve defining it.

The terragrunt.stack.hcl file

A stack is declared in a file literally named terragrunt.stack.hcl (distinct from terragrunt.hcl, which is a unit). It is HCL — same lexer, expression language, and type system — and supports locals and Terragrunt’s functions, so you can compute values, read shared .hcl files with read_terragrunt_config, and anchor paths with get_repo_root()/get_terragrunt_dir(). What it contains is a list of unit blocks (the common case) and optionally stack blocks (to nest a whole stack). It contains no terraform/remote_state/provider of its own — those belong to the units it generates.

# live/prod/terragrunt.stack.hcl
locals {
  env  = "prod"
  tmpl = "${get_repo_root()}/units"   # where the unit templates live
}

unit "vpc" {
  source = "${local.tmpl}/vpc"
  path   = "vpc"
  values = { cidr = "10.10.0.0/16", environment = local.env }
}

unit "rds" {
  source = "${local.tmpl}/rds"
  path   = "rds"
  values = { instance_class = "db.r6g.large", environment = local.env }
}

unit "app" {
  source = "${local.tmpl}/app"
  path   = "app"
  values = { replicas = 4, environment = local.env }
}

generate in live/prod turns this into live/prod/.terragrunt-stack/vpc/, .../rds/, .../app/, each a real unit ready to run. The staging stack file is the same three blocks with smaller values — the shape is identical, only the parameters differ.

The unit block: every attribute

unit "<name>" declares one generated unit. The label ("vpc") names the block; path names the directory it lands in. Here is the complete attribute set.

Attribute Type Default What it does · gotcha
source string — (required) Where the unit template comes from — a local path (../../units/vpc, or anchored with get_repo_root()) or a remote getter (Git git::...//subdir?ref=, registry tfr:///..., S3/GCS/HTTP). This is the source of the unit’s config (its terragrunt.hcl and friends), not of a Terraform module. Pin remote sources with ?ref=/?version= — an unpinned blueprint is not reproducible.
path string — (required) The destination directory relative to .terragrunt-stack/ where the unit is generated (vpc, or nested like network/vpc). It becomes the unit’s identity — and, because Terragrunt derives the state key from the unit’s path, it becomes part of the state key too. Choose paths deliberately.
values map / object {} Arbitrary data passed into the generated unit, read there as values.<key>. The parameter channel that makes the blueprint reusable (full treatment below). Values must be expressible in HCL (strings, numbers, bools, lists, maps, objects).
no_dot_terragrunt_stack bool false Generate the unit outside the .terragrunt-stack/ directory (i.e. directly under the stack-file directory) instead of inside it. Use sparingly — when you want generated units to live at a path not nested under .terragrunt-stack/. The default (inside) keeps generated output clearly separated and easy to .gitignore.
no_validation bool false Skip Terragrunt’s post-generation validation of this unit. Off by default so a malformed generated unit is caught at generate time; turn on only to work around a known false positive.

A note on naming, because the two labels confuse newcomers: the block label (unit "vpc") is the logical name used in messages and for nested addressing; the path is the on-disk directory. They are usually the same string, but they need not be — unit "primary_db" { path = "rds" ... } is legal. Keep them aligned unless you have a reason not to.

The stack block: nesting stacks

stack "<name>" instantiates a whole nested stack rather than a single unit. It has the same shape as unitsource, path, values — but source points at something that contains its own terragrunt.stack.hcl, so generating the parent recurses and stamps out the child stack’s units too. This is how you compose: a top-level “platform” stack that pulls in a “network” stack and a “data” stack, each itself a bundle of units.

Attribute Type Default What it does
source string — (required) A location containing a terragrunt.stack.hcl — the nested blueprint to instantiate (local path or remote getter, pinned).
path string — (required) Where the nested stack is generated, relative to the parent’s .terragrunt-stack/. The nested stack’s own units land beneath this.
values map / object {} Values passed into the nested stack; the nested terragrunt.stack.hcl reads them as values.* and can forward them down to its own units. The channel that lets a parent parametrise a child stack.
no_dot_terragrunt_stack bool false As for unit — generate the nested stack outside .terragrunt-stack/.
no_validation bool false Skip validation of the generated nested stack.
# live/prod/terragrunt.stack.hcl  — a stack composed of nested stacks
stack "network" {
  source = "${get_repo_root()}/stacks/network"   # contains its own terragrunt.stack.hcl
  path   = "network"
  values = { cidr = "10.10.0.0/16", azs = 3 }
}

stack "data" {
  source = "${get_repo_root()}/stacks/data"
  path   = "data"
  values = { engine = "postgres", multi_az = true }
}

Nesting is what lets a blueprint scale from “three units in a row” to “an entire account’s worth of bundles,” composed from smaller, independently versioned stacks. Generation recurses depth-first, so live/prod/.terragrunt-stack/network/.terragrunt-stack/... is the shape on disk — nested stacks generate nested .terragrunt-stack/ directories.

no_stack is a related modifier you will meet in unit templates (not in the stack file). A file marked with the no_stack convention is copied through generation as-is rather than being treated as part of the generated unit’s normal processing — useful for static assets a template ships that should land verbatim in the generated unit. Treat it as the “copy this through untouched” escape hatch; the common path never needs it.

values and values.*: parametrising a generated unit

values is the heart of what makes Stacks more than a copy script. Understanding exactly where it flows — and how it differs from the three channels you already know — is the thing interviewers probe and the thing that makes blueprints reusable.

The flow. You set values on a unit (or stack) block in the stack file. Terragrunt writes those values alongside the generated unit, and the generated unit’s terragrunt.hcl reads them back through the values.<key> reference. So the data path is:

terragrunt.stack.hcl  (unit "rds" { values = { instance_class = "db.r6g.large" } })
        │  generate
        ▼
.terragrunt-stack/rds/terragrunt.hcl   reads  values.instance_class
        │  init/plan/apply
        ▼
the rds module runs with that size

The generated unit decides what to do with the values. Most often it turns them into inputs for the module, but it can also use a value to pick a module source ref, derive a locals, or gate a generate block:

# units/rds/terragrunt.hcl  — a unit template that consumes values.*
terraform {
  source = "${get_repo_root()}/modules//rds?ref=v1.4.0"
}

inputs = {
  instance_class = values.instance_class     # from the stack file
  environment    = values.environment
}

values vs the three channels you already know. This table is the disambiguation to memorise:

Channel Flows from → to Read as Purpose
values stack file unit/stack block → generated unit config values.<key> Parametrise the blueprint — make one unit template produce many different units. Generation-time.
inputs a unit’s terragrunt.hcl → the Terraform module (as TF_VAR_*) (the module’s var.<x>) Feed the module’s variables. Run-time, per unit.
locals within a single config file local.<key> Compute/derive values inside one file (unit or stack file).
dependency outputs another applied unit → this unit dependency.<name>.outputs.<key> Pass runtime results (a real VPC ID) between units in the DAG.

The crisp distinctions: values is generation-time input to a unit template; inputs is run-time input to a module. values is set in the stack file; inputs is set in the unit. And values is not how you pass a VPC ID from a network unit to an app unit — that is still dependency outputs, resolved at run-time from real state, because at generation time the VPC does not exist yet. A common beginner error is trying to thread a dependency’s output through values; it cannot work, because generation happens before any apply. Use values for static parameters (sizes, CIDRs, names, counts, refs) and dependency for dynamic results.

Where values lives on disk. Terragrunt persists each generated unit’s values so the unit can read them after generation (this is what makes values.* resolvable in the generated terragrunt.hcl). You do not write or edit that file by hand — it is generated output under .terragrunt-stack/ — but knowing it exists explains how a value set in the stack file becomes readable in the unit: it is materialised next to the generated unit at generate time.

The .terragrunt-stack/ working tree

generate produces a directory named .terragrunt-stack/ next to the stack file. Inside it is one subdirectory per unit (at the path you named), each containing the generated unit — its terragrunt.hcl (from the template’s source), any files the template shipped, and the materialised values. Nested stack blocks produce nested .terragrunt-stack/ directories.

live/prod/
  terragrunt.stack.hcl
  .terragrunt-stack/          # GENERATED — git-ignore it
    vpc/
      terragrunt.hcl
    rds/
      terragrunt.hcl
    app/
      terragrunt.hcl

Three operational facts follow:

The terragrunt stack commands

Stacks add a stack command group plus an interaction with the familiar run verbs.

Command What it does · notes
terragrunt stack generate Reads the terragrunt.stack.hcl in the current directory and materialises .terragrunt-stack/ — fetching each unit/stack source, copying it to its path, and writing values. The explicit “build the tree” step. Run it after changing the blueprint, a unit template, or values.
terragrunt stack run <cmd> Generates if needed, then runs <cmd> (plan, apply, destroy, validate, output, …) across every unit in the generated stack, in DAG order — the stack-aware equivalent of run --all. terragrunt stack run plan is the everyday command for planning a whole bundle.
terragrunt stack output Aggregates the outputs of every unit in the stack into one structured result (keyed by unit), so you can read the whole bundle’s outputs at once rather than unit-by-unit. Useful for feeding a stack’s results to another system.
terragrunt run --all <cmd> (in a stack dir) A run --all invoked where a terragrunt.stack.hcl is present will generate the stack first, then run across the generated units — so existing run --all muscle memory and CI keep working against stack files. terragrunt stack run is the explicit, stack-native form.

Two clarifications. First, generate is implicit in run: you rarely call stack generate by hand in CI because stack run/run --all generate first; you call it explicitly when you want to inspect the produced tree (or to fail fast on a generation error) before running anything. Second, the DAG, ordering, parallelism, and selective-execution flags from the monorepo lesson (--parallelism, --filter-affected, --queue-*, reverse-order destroy) apply to the generated units exactly as they do to hand-built ones — Stacks change where the units come from, not how they are orchestrated once they exist.

cd live/prod

# Build the tree from the blueprint and inspect it.
terragrunt stack generate
ls .terragrunt-stack/            # vpc/ rds/ app/

# Plan the whole bundle (generates first, runs in DAG order).
terragrunt stack run plan

# Apply it, capped for provider rate limits, non-interactive for CI.
terragrunt stack run apply --parallelism 6 --non-interactive

# Read every unit's outputs at once.
terragrunt stack output

# Tear the bundle down in reverse dependency order.
terragrunt stack run destroy --non-interactive

Terragrunt Stacks vs Terraform/OpenTofu Stacks — the name clash, cleared up

This is the single most important disambiguation in the lesson, and a guaranteed interview trap: two unrelated features, from two different vendors, both called “Stacks.”

The table makes the contrast unmissable:

Terragrunt Stacks Terraform / OpenTofu Stacks
Vendor Gruntwork (Terragrunt) HashiCorp / OpenTofu (the engine)
Files terragrunt.stack.hcl *.tfstack.hcl + *.tfdeploy.hcl
Core blocks unit, stack, values component, deployment, variable, output, provider
What it produces A generated tree of Terragrunt units Native components rolled out to deployments
Runs via The Terragrunt CLI against terraform/tofu The engine itself / HCP Terraform
Layer A wrapper above Terraform Inside Terraform
Mental model “Generate my units from a blueprint” “Define components and the deployments they ship to, natively”

The two solve overlapping problems — instantiate the same infrastructure many times with different parameters — by completely different means at different layers. They are not interchangeable, you do not use both files together, and “Terragrunt Stacks vs Terraform Stacks” is precisely the question that separates someone who has read a headline from someone who has used the tools. If a job spec or interviewer says “stacks,” your first move is to ask which — the wrapper’s or the engine’s. (The config-language lesson notes this clash too; this is the full treatment.)

When to use Stacks vs classic units

Stacks are powerful but not free — they add a generation step, a .terragrunt-stack/ artefact, and a still-stabilising feature surface. Choose deliberately.

Situation Reach for… Why
You instantiate the same bundle of components many times (per env / per region / per tenant) with only parameter differences Stacks The blueprint is written once; new instantiations are a new terragrunt.stack.hcl with different values, not a hand-stamped subtree.
A handful of bespoke units that differ structurally, not just by parameter Classic units There is no blueprint to factor out; a blueprint of one is just indirection.
You need to review the exact tree that will be deployed in the PR Classic units (or commit-then-review) Hand-built units are the diff; with Stacks the units are generated, so review the blueprint + templates and the generated tree separately.
A mature repo already on run --all with hand-built units, working fine Stay (adopt Stacks incrementally) Stacks generate the same units run --all consumes, so you can convert one bundle to a blueprint at a time without a big-bang migration.
You want the newest, most-supported direction and can absorb some rough edges Stacks This is where Terragrunt is heading on the road to 1.0; new greenfield bundles are the natural place to adopt them.
You are in a regulated/change-controlled shop that must diff every resource pre-merge Classic units, or Stacks with the generated tree committed Generation indirection can complicate “the PR is the change” review unless you commit or carefully gate the generated output.

The relationship to the DRY run-all + find_in_parent_folders pattern is one of complement, not replacement at the config layer. That pattern (a root terragrunt.hcl carrying remote_state/generate, pulled in by every unit via include "root" { path = find_in_parent_folders("root.hcl") }) keeps backend/provider config DRY within a unit. Stacks keep the set and shape of units DRY across a bundle. They stack (no pun intended): a generated unit still includes a root for its backend/provider, and the stack blueprint decides which units exist. Stacks remove the directory-level duplication that find_in_parent_folders never addressed.

Architecture overview

Terragrunt Stacks: a terragrunt.stack.hcl blueprint generating a .terragrunt-stack tree of units, with values flowing in and stack run/output across them

The diagram puts the whole feature on one canvas: a terragrunt.stack.hcl holding unit "vpc", unit "rds", unit "app" (and a nested stack block) with values annotated on each; a generate arrow into the .terragrunt-stack/ tree of real units; each generated unit reading values.* into its inputs and include-ing the shared root for backend/provider; dependency edges between the generated units feeding the DAG; and stack run/stack output operating across the lot. Off to one side, a clearly separated box shows HashiCorp’s Terraform Stacks (*.tfstack.hcl components + *.tfdeploy.hcl deployments) — drawn apart precisely to reinforce that it is a different layer from a different vendor. It is the mental index for everything tabulated above: blueprint → generation → units → orchestration, with the name-clash quarantined in its own corner.

Hands-on lab

This lab builds a real blueprint and instantiates it twice — a dev and a staging environment — entirely on the local backend with the null/random providers, so it runs offline, costs nothing, and needs no cloud account. You will write unit templates, a terragrunt.stack.hcl that consumes values, generate the .terragrunt-stack/ tree, run it, read aggregated outputs, and prove the same blueprint produces two differently-sized environments. You need a current terragrunt and terraform (or tofu) on your PATH.

1. Scaffold the repo shape.

mkdir -p tg-stacks-lab/modules/{net,app} \
         tg-stacks-lab/units/{net,app} \
         tg-stacks-lab/live/dev tg-stacks-lab/live/staging
cd tg-stacks-lab

2. Two tiny modules. Create modules/net/main.tf:

variable "cidr"        { type = string }
variable "environment" { type = string }
resource "random_id" "vpc" { byte_length = 4 }
output "vpc_id" { value = "vpc-${random_id.vpc.hex}" }
output "cidr"   { value = var.cidr }

Create modules/app/main.tf:

variable "replicas"    { type = number }
variable "environment" { type = string }
variable "vpc_id"      { type = string }
resource "null_resource" "app" {
  triggers = { replicas = var.replicas, env = var.environment, vpc = var.vpc_id }
}
output "app_summary" { value = "${var.environment}: ${var.replicas} replicas in ${var.vpc_id}" }

3. A DRY root for backend/provider, shared by every generated unit. Create live/root.hcl:

remote_state {
  backend  = "local"
  generate = { path = "backend.tf", if_exists = "overwrite_terragrunt" }
  config   = { path = "${get_terragrunt_dir()}/terraform.tfstate" }
}

generate "versions" {
  path      = "versions.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<-EOF
    terraform {
      required_providers {
        random = { source = "hashicorp/random" }
        null   = { source = "hashicorp/null" }
      }
    }
  EOF
}

4. Unit templates that consume values.*. Create units/net/terragrunt.hcl:

include "root" { path = find_in_parent_folders("root.hcl") }

terraform { source = "${get_repo_root()}/modules//net" }

inputs = {
  cidr        = values.cidr           # from the stack file
  environment = values.environment
}

Create units/app/terragrunt.hcl (note the dependency on the generated net sibling — runtime data still flows through dependency, not values):

include "root" { path = find_in_parent_folders("root.hcl") }

terraform { source = "${get_repo_root()}/modules//app" }

dependency "net" {
  config_path  = "../net"
  mock_outputs = { vpc_id = "vpc-mock0000" }
  mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}

inputs = {
  replicas    = values.replicas        # static parameter via values
  environment = values.environment
  vpc_id      = dependency.net.outputs.vpc_id   # dynamic result via dependency
}

5. The blueprint, instantiated for dev. Create live/dev/terragrunt.stack.hcl:

locals {
  env  = "dev"
  tmpl = "${get_repo_root()}/units"
}

unit "net" {
  source = "${local.tmpl}/net"
  path   = "net"
  values = { cidr = "10.0.0.0/16", environment = local.env }
}

unit "app" {
  source = "${local.tmpl}/app"
  path   = "app"
  values = { replicas = 1, environment = local.env }
}

6. The same blueprint, bigger values, for staging. Create live/staging/terragrunt.stack.hcl:

locals {
  env  = "staging"
  tmpl = "${get_repo_root()}/units"
}

unit "net" {
  source = "${local.tmpl}/net"
  path   = "net"
  values = { cidr = "10.1.0.0/16", environment = local.env }
}

unit "app" {
  source = "${local.tmpl}/app"
  path   = "app"
  values = { replicas = 3, environment = local.env }   # bigger than dev
}

7. Generate the dev tree and inspect it.

cd live/dev
terragrunt stack generate
find .terragrunt-stack -name terragrunt.hcl

Expected: .terragrunt-stack/net/terragrunt.hcl and .terragrunt-stack/app/terragrunt.hcl exist — the blueprint has stamped out two real units from the templates.

8. Plan and apply the whole bundle in DAG order.

terragrunt stack run plan
terragrunt stack run apply --non-interactive

Expected: net plans/applies before app (the dependency edge orders them), and the plan shows replicas = 1, cidr = "10.0.0.0/16" — the dev values flowed through values.* into inputs.

9. Read the bundle’s aggregated outputs.

terragrunt stack output

Expected: a structured result containing both units’ outputs — net’s vpc_id/cidr and app’s app_summary reading dev: 1 replicas in vpc-....

10. Instantiate the same blueprint as staging and confirm it differs only by parameter.

cd ../staging
terragrunt stack run apply --non-interactive
terragrunt stack output

Expected: the identical two-unit shape, but app_summary now reads staging: 3 replicas in vpc-... — one blueprint, two environments, differing only in values. That is the whole point of Stacks made concrete.

11. Cleanup.

terragrunt stack run destroy --non-interactive       # tears down staging
cd ../dev
terragrunt stack run destroy --non-interactive       # tears down dev
cd ../..
rm -rf tg-stacks-lab

Cost note: zero. The lab uses the local backend and the null/random providers — nothing is created in any cloud, so there is nothing to bill; cleanup is destroy plus deleting the directory. (The generated .terragrunt-stack/ trees vanish with it.)

Common mistakes & troubleshooting

Symptom Cause Fix
values.<x> is unknown / unresolved in a generated unit The unit template references a values key the stack file’s unit block never set Set the key in the unit/stack block’s values = { ... }; every values.* a template reads must be supplied.
Trying to pass a VPC ID through values and getting a stale/empty value values is generation-time static data; the VPC does not exist yet when the tree is generated Use a dependency block for runtime results (dependency.net.outputs.vpc_id); reserve values for static parameters.
Edits to a file under .terragrunt-stack/ disappear .terragrunt-stack/ is generated output; the next generate/run overwrites it Edit the unit template (the source) or the blueprint, then regenerate. Never edit generated units.
.terragrunt-stack/ committed to git, noisy diffs The generated tree is checked in .gitignore .terragrunt-stack/ (and .terragrunt-cache/); commit only blueprints and templates — unless your change-control process requires committing the generated tree.
A blueprint change didn’t take effect at run-time run generates first, but you inspected a stale .terragrunt-stack/ from before the change Re-run stack run (it regenerates) or stack generate explicitly, then inspect; do not read an old generated tree.
Confusing *.tfstack.hcl with terragrunt.stack.hcl The Terraform-Stacks vs Terragrunt-Stacks name clash Terragrunt uses terragrunt.stack.hcl (unit/stack/values); HashiCorp uses *.tfstack.hcl + *.tfdeploy.hcl (component/deployment). Different vendors, different files.
Remote source produces inconsistent generated units across runs The unit/stack source is unpinned, so the template drifts Pin every remote source with ?ref=/?version= so a blueprint generates identically over time.
Generated unit’s state key isn’t where you expected The key derives from the unit’s path, which lives under .terragrunt-stack/ Choose path deliberately (it is part of the identity/state key); if you changed a path, you have moved the unit’s state — migrate it.

Best practices

Security notes

Interview & exam questions

  1. What is the difference between a unit and a stack in Terragrunt? A unit is a directory with a terragrunt.hcl — one module instantiation, one DAG node, one state key. A stack is a terragrunt.stack.hcl blueprint that lists unit (and nested stack) blocks and, when generated, produces a tree of those units. A unit is the atom; a stack is a recipe for many atoms.

  2. What does terragrunt stack generate actually do? It reads the terragrunt.stack.hcl in the current directory, fetches each unit/stack source, copies it to the path you named inside .terragrunt-stack/, and materialises each unit’s values. The output is an ordinary tree of units the normal DAG/run --all machinery then operates on.

  3. How does values differ from inputs? values is generation-time data set on a unit/stack block in the stack file and read in the generated unit as values.* — it parametrises the blueprint. inputs is run-time data set in a unit’s terragrunt.hcl and passed to the Terraform module as TF_VAR_*. values customises which/what units get generated; inputs feeds the module’s variables.

  4. Can you pass a dependency’s output through values? Why or why not? No. values is resolved at generation time, before any unit is applied, so a runtime result (a real VPC ID) does not exist yet. Runtime results flow through dependency outputs (dependency.net.outputs.vpc_id), resolved from real state at run-time. values is for static parameters only.

  5. What is in .terragrunt-stack/, and should you commit it? It is the generated tree of real units (and nested stacks) that the blueprint stamped out — terragrunt.hcl files, template files, materialised values. It is build output: .gitignore it and treat the blueprint + templates as the source of truth, unless a change-control process specifically requires committing the generated tree.

  6. How do Terragrunt Stacks relate to run --all? run --all runs a tree of units in DAG order but never created them. Stacks add the creation step — generate the units from a blueprint — and the same DAG/run --all machinery then runs them. So Stacks replace the manual authoring of the tree, not the running of it; hence “the evolution beyond run-all.”

  7. Distinguish Terragrunt Stacks from Terraform/OpenTofu Stacks. Terragrunt Stacks are a Gruntwork wrapper feature: terragrunt.stack.hcl, unit/stack/values, generating Terragrunt units that run against terraform/tofu. Terraform/OpenTofu Stacks are a HashiCorp/OpenTofu engine feature: *.tfstack.hcl (components) + *.tfdeploy.hcl (deployments), a native multi-deployment construct (HCP-run in HashiCorp’s case). Different vendors, different files, different layers — not interchangeable.

  8. When would you choose a hand-built tree of units over Stacks? When the units are bespoke (they differ structurally, not just by parameter, so there is no blueprint to factor out), when you need the PR diff to be the exact tree being deployed, or in a change-controlled shop that must review every resource pre-merge without generation indirection. Stacks shine when you instantiate the same bundle many times with only parameter differences.

  9. Every attribute of the unit block? source (where the unit template comes from — local or pinned remote), path (destination under .terragrunt-stack/, which becomes part of the state key), values (the parameter map read as values.*), no_dot_terragrunt_stack (generate outside .terragrunt-stack/), and no_validation (skip post-generation validation).

  10. What is the stack block (inside a stack file) for? It instantiates a nested stack — its source points at a location containing its own terragrunt.stack.hcl, so generation recurses and stamps the child stack’s units beneath the parent’s path. Same source/path/values shape as unit; it is how you compose larger topologies from smaller, independently versioned stacks.

  11. How do Stacks relate to the DRY run-all + find_in_parent_folders pattern? They are complementary at different scopes. find_in_parent_folders("root.hcl") keeps backend/provider config DRY within a unit (every generated unit still includes the root). Stacks keep the set and shape of units DRY across a bundle. Stacks remove the directory-level duplication that find_in_parent_folders never addressed.

  12. Do orchestration flags like --filter-affected and --parallelism work with Stacks? Yes — they apply to the generated units exactly as to hand-built ones. Stacks change where the units come from (a blueprint), not how they are run once they exist; the DAG, ordering, parallelism, selective execution, and reverse-order destroy all behave identically.

Quick check

  1. Which file declares a Terragrunt Stack, and which blocks does it contain?
  2. Where do generated units land, and should that directory be committed?
  3. How is values read inside a generated unit, and at what time is it resolved?
  4. Which channel passes a runtime VPC ID into an app unit — values or dependency?
  5. Name the two files that define HashiCorp’s Terraform Stacks (the other “stacks”).

Answers

  1. terragrunt.stack.hcl, containing unit blocks (and optionally nested stack blocks), each with source/path/values.
  2. In .terragrunt-stack/ (one subdirectory per unit at its path); it is generated output, so .gitignore it — commit blueprints and templates, not the generated tree (unless change-control requires it).
  3. As values.<key> in the generated unit’s terragrunt.hcl, resolved at generation time (not run-time).
  4. dependency (dependency.net.outputs.vpc_id) — values is static, generation-time data and cannot carry a runtime result.
  5. *.tfstack.hcl (components) and *.tfdeploy.hcl (deployments) — HashiCorp/OpenTofu’s engine-native Stacks, distinct from Terragrunt’s terragrunt.stack.hcl.

Exercise

Extend the lab’s blueprint to exercise the rest of the Stacks surface:

  1. Add a third unit (cache) to both the dev and staging terragrunt.stack.hcl, sourced from a new units/cache template that reads values.node_count, and wire app to depend on it. Prove with terragrunt stack run plan that the new unit is generated, ordered correctly in the DAG, and sized by its values (small in dev, larger in staging).
  2. Compose with a nested stack. Factor net + cache into a stacks/platform/terragrunt.stack.hcl, then replace those two unit blocks in live/dev/live/staging with a single stack "platform" block that forwards values down. Confirm the nested .terragrunt-stack/platform/.terragrunt-stack/... shape on disk and that stack run apply still works end to end.
  3. Pin a remote template. Change one unit’s source to a pinned Git URL (git::...//units/app?ref=<tag>) and show that stack generate produces an identical unit on repeat runs; then bump the ref and confirm the regenerated unit changes.
  4. Read the whole bundle’s outputs and feed them onward. Use terragrunt stack output to capture every unit’s outputs for one environment, and pipe the structured result into a file you could hand to another system — demonstrating the aggregate-outputs use case.

Success looks like: a three-unit blueprint instantiated as two differently-sized environments, a nested stack composing a reusable platform sub-bundle, a pinned remote template that generates reproducibly, and a single aggregated stack output for the whole bundle.

Certification mapping

This lesson supports the HashiCorp Certified: Terraform Associate (003) objectives — with the standing caveat that Terragrunt is a third-party tool and the exam tests Terraform itself; Terragrunt (and its Stacks) is the production wrapper that exercises those concepts at scale:

For the exam, be crisp on the Terraform primitives underneath: a module instantiated many times, per-instance state, and terraform_remote_state/outputs for cross-instance data (the manual alternative to dependency). Stacks change who authors the tree, not the underlying Terraform model the exam tests.

Glossary

Next steps

You now have the full Terragrunt Stacks picture — units vs stacks, the unit/stack blocks attribute by attribute, the values/values.* parameter channel, the .terragrunt-stack/ working tree, the stack generate/run/output commands, and a clear line between Terragrunt Stacks and HashiCorp’s Terraform Stacks. To go deep on the config language the generated units are written in — every block and attribute of terragrunt.hcl, the function catalogue, hooks, and the errors model — see Terragrunt Configuration, In Depth: Every Block, Function & Hook in terragrunt.hcl. To go deep on orchestrating the tree once it exists — the DAG, run --all/run --graph, --filter-affected, parallelism, and CI at scale — read Scaling Terragrunt Monorepos with Dependency Graphs and run-all. Then put all three together end to end in Multi-Environment 3-Tier Infrastructure with Terragrunt & CI/CD Approval Gates, where DRY configuration, a dependency-ordered tree, and graduated approval gates drive a real dev→uat→staging→prod promotion pipeline.

TerragruntTerraformOpenTofuStacksDRYDevOps
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments