Multi-account & multi-region layouts
Structure a real estate.
A posted letter carries its routing on the outside. Country, city, street, house number, in that order, and no sorting office ever opens the envelope to work out where it goes. A Terragrunt repository works the same way, except the address is written in folder names and the sorting runs backwards. Terragrunt (a thin wrapper around Terraform or OpenTofu that generates the repetitive parts of a config for you) starts at the unit you are running and climbs upward, picking up the environment first, then the region, then the account. A unit is one folder holding one terragrunt.hcl file: the smallest thing you can apply. That climb carries real weight. The folder a unit sits in decides which cloud account it deploys into, which region it lands in, which state file it writes, and which role it borrows to get there. State is Terraform's ledger of every resource it manages, and it is stored as plain text. Get the shape right and one root file drives hundreds of units with zero copy-paste. Get it wrong and a single file in the wrong drawer quietly aims production at somewhere you never meant.
The Folder Path Is the Address
The nesting order that scales is strict and dull: account, then region, then environment, then component. One question per level, always in the same sequence. At each level you drop a tiny file written in HCL (HashiCorp Configuration Language, the language Terraform and Terragrunt configs are written in) that states only that level's facts. account.hcl knows the account number. region.hcl knows the region. env.hcl knows the environment name. The bottom of the tree is a leaf folder holding exactly one terragrunt.hcl, and that one file maps to exactly one state file. Your reusable modules live somewhere else entirely, in their own repository with version tags on them. What you are looking at here is the live repo: thin wrappers that say which module, deployed where, with what settings.
# Every file that shapes where a unit deploysfind live -name '*.hcl' | sort
live/prod/account.hcllive/prod/eu-west-1/app/env.hcllive/prod/eu-west-1/app/vpc/terragrunt.hcllive/prod/eu-west-1/region.hcllive/prod/us-east-1/app/eks/terragrunt.hcllive/prod/us-east-1/app/env.hcllive/prod/us-east-1/app/rds/terragrunt.hcllive/prod/us-east-1/app/vpc/terragrunt.hcllive/prod/us-east-1/region.hcllive/root.hcllive/security/account.hcllive/security/us-east-1/audit/cloudtrail/terragrunt.hcllive/security/us-east-1/audit/env.hcllive/security/us-east-1/region.hcllive/staging/account.hcllive/staging/us-east-1/app/eks/terragrunt.hcllive/staging/us-east-1/app/env.hcllive/staging/us-east-1/app/vpc/terragrunt.hcllive/staging/us-east-1/region.hcl
Nineteen files. Three accounts, one of them a dedicated security account. Seven deployable units, and they are ordinary things: networks (vpc, short for Virtual Private Cloud, an isolated network inside one account), Kubernetes clusters (eks, Amazon's managed Kubernetes service), a database (rds, Amazon's managed relational database service), and one audit trail in the security account (cloudtrail, the record of every API call made in an AWS account). The fourth level is worth a second look. Here the environments are called app and audit, which is what that level is for: separate stacks that share an account and a region but should never share a state file or a failure. Now notice what is absent. No backend blocks, no provider blocks, no account number typed out in seven places where six of them can drift. Then look at the depth. Every account.hcl sits exactly two levels below live/, every region.hcl three, every env.hcl four. Treat that regularity as a contract rather than a habit. Later in this lesson you enforce it in CI (continuous integration, the automation that checks every pull request before it merges), because breaking that contract is the cheapest way anyone has found to point a unit at the wrong account without touching a single line of Terraform.
Each Level Carries Only Its Own Facts
A level fragment is a locals block and nothing else. No include, no terraform block, no dependency. The reason is mechanical: the root reads these files with read_terragrunt_config(), which parses each one as a full Terragrunt config. Put an include in a fragment and it gets evaluated during that read. Put a dependency in one and you have turned a constant into a remote lookup that fires every time anything parses. A fragment is the label on the outside of a drawer. It is never the machine inside the drawer.
# The only file in the repo that knows what "prod" means.locals {account_name = "prod"aws_account_id = "111122223333"# Credentials are a property of the account, so they live here too.# An ARN (Amazon Resource Name) is AWS's globally unique id for one thing,# here an IAM (Identity and Access Management) role you are allowed to borrow.deploy_role_arn = "arn:aws:iam::111122223333:role/terragrunt-deploy"}
locals {aws_region = "us-east-1"}# ... and one level further down, live/prod/us-east-1/app/env.hcl:## locals {# environment = "app-prod"# }
One Root File, Read Once Per Unit
Here is the part that catches people out. The root file reads those fragments with find_in_parent_folders(), and that function does not resolve relative to the root file. It resolves relative to the unit you ran. Think of someone stepping out of their own front door and knocking on doors up the street until somebody answers. Terragrunt starts in the unit's parent folder, moves up one directory at a time, and takes the first file with a matching name it finds. So the same twenty lines of root config hand back a different account, a different region and a different state key for every leaf in the tree. When you genuinely need the root's own directory instead, say to point at a shared script sitting next to root.hcl, get_parent_terragrunt_dir() gives you that.
# Every function here runs in the context of the UNIT being applied,# not this file, so each unit works out its own answers.locals {account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl"))region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))account_id = local.account_vars.locals.aws_account_idaws_region = local.region_vars.locals.aws_regionenvironment = local.env_vars.locals.environment}# Credentials come from the folder, not from your shell. Terragrunt assumes# this role itself, so the S3 backend and the AWS provider share one session.# The session name lands in CloudTrail, so every run stays attributable.iam_role = local.account_vars.locals.deploy_role_arniam_assume_role_duration = 3600 # secondsiam_assume_role_session_name = "tg-${local.environment}"# State lives in the account it describes. Prod state is simply not# present in the staging bucket, so staging access cannot reach it.remote_state {backend = "s3"generate = { path = "backend.tf", if_exists = "overwrite_terragrunt" }config = {bucket = "acme-tfstate-${local.account_id}-${local.aws_region}"key = "${path_relative_to_include()}/terraform.tfstate"region = local.aws_regionencrypt = trueuse_lockfile = true # S3-native locking (Terraform/OpenTofu 1.10+),} # so there is no DynamoDB table to create}generate "provider" {path = "provider.tf"if_exists = "overwrite_terragrunt"contents = <<EOFprovider "aws" {region = "${local.aws_region}"# Hard-fail if the credentials that actually resolved live elsewhere.allowed_account_ids = ["${local.account_id}"]default_tags {tags = {Environment = "${local.environment}"ManagedBy = "terragrunt"}}}EOF}
Three decisions in that file are security decisions wearing style clothing. First, path_relative_to_include() returns the path from root.hcl down to the unit, so the state key is the folder path and two units can never land on the same state file. Second, the bucket name carries the account id and the region, which puts prod state inside prod. A role that can read the staging bucket cannot read prod state, because prod state is not sitting there. Third, iam_role sits at the root rather than inside the generated provider. Terragrunt makes the AssumeRole call itself, using STS (the AWS Security Token Service, which swaps your identity for short-lived keys in another account), and hands the resulting temporary keys to Terraform. That means the S3 backend (Simple Storage Service, Amazon's object storage, where the state file lives) and the AWS provider run on the same session. An assume_role block written inside a provider covers the provider alone. That gap is how teams end up authenticating to one account while writing state into another.
One naming detail matters more than it looks. Call the root file root.hcl, never terragrunt.hcl. Terragrunt treats any folder holding a terragrunt.hcl as a runnable unit, so a root named terragrunt.hcl makes the top of your tree look like something you can apply, and it lets an argument-less find_in_parent_folders() land on it by accident. Current Terragrunt ships a strict-mode control called root-terragrunt-hcl that turns exactly that situation into an error. Name it root.hcl and always pass the filename explicitly.
The Leaf Declares Almost Nothing
include "root" {path = find_in_parent_folders("root.hcl") # nearest live/root.hcl}terraform {# Pinned tag, never a branch: the module is the other half of this address.source = "git::ssh://[email protected]/acme/tf-modules.git//eks?ref=v2.4.0"}# Only what is unique to THIS cluster. Account, region, tags, state key# and credentials are all inherited from where this file sits.inputs = {cluster_name = "app-prod"node_count = 6}
Adding a region is a copy of a region folder plus a one-line edit. Adding an account is a new top-level folder with its own account.hcl. Nothing else changes, anywhere. And when you get the copy half right, the failure is loud, which is exactly the behaviour you want from a tree that decides where money gets spent.
# Stand up a new region by copying an existing unit, then run itmkdir -p live/prod/us-west-2/app/vpccp live/prod/us-east-1/app/vpc/terragrunt.hcl live/prod/us-west-2/app/vpc/cd live/prod/us-west-2/app/vpcterragrunt plan
14:07:22 ERROR Error: Error in function call14:07:22 ERROR on /home/deploy/live/root.hcl line 5, in locals:14:07:22 ERROR 5: region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))14:07:22 ERROR14:07:22 ERROR Call to function "find_in_parent_folders" failed: Could not find a region.hcl in any of the parent folders of /home/deploy/live/prod/us-west-2/app/vpc/terragrunt.hcl. Cause: Traversed all the way to the root.14:07:22 ERROR Unable to determine underlying exit code, so Terragrunt will exit with error code 1
You forgot region.hcl and env.hcl. Terragrunt stops at the first level it cannot find, reports it, and refuses to guess the rest. Read that error as the layout doing its job. The dangerous version of this repo is the one where somebody, tired of seeing that message, drops a helpful default region.hcl at the top of live/. Now the same run succeeds. The climb walks straight past the empty us-west-2 folder, finds the default several levels higher, and your new region builds itself in us-east-1 under a state key that says us-west-2. If a level genuinely is optional, say so out loud: find_in_parent_folders() takes a second argument, a fallback value it returns when the search comes up empty, which puts the default in one readable place instead of hiding it in the tree.
Prove the Folder Decided Before You Apply
Reading HCL and hoping is not verification. Terragrunt will print the fully merged configuration as JSON (JavaScript Object Notation, a plain-text format for structured data), which is the receipt for everything the tree computed. Ask it for the three fields that set your blast radius, meaning how much one command can reach: the bucket, the state key, and the role.
# from the repo rootcd live/prod/us-east-1/app/eks# Resolve the merged config (writes terragrunt.rendered.json), then read it.# jq is a small command-line tool for pulling fields out of JSON.terragrunt render --json -wjq -r '.remote_state.config.bucket, .remote_state.config.key, .iam_role' terragrunt.rendered.json
acme-tfstate-111122223333-us-east-1prod/us-east-1/app/eks/terraform.tfstatearn:aws:iam::111122223333:role/terragrunt-deploy
Every value there came out of the path. None of it is written in the leaf file. Run the same check from two folders in different accounts and you can watch one root config split cleanly in two.
# Same root.hcl, two folders, two accountsfor u in prod/us-east-1/app/eks staging/us-east-1/app/vpc; do( cd "live/$u" \&& terragrunt render --json -w >/dev/null \&& echo "$u -> $(jq -r .remote_state.config.bucket terragrunt.rendered.json)" )done
prod/us-east-1/app/eks -> acme-tfstate-111122223333-us-east-1staging/us-east-1/app/vpc -> acme-tfstate-444455556666-us-east-1
Now the other half of the address. The folder decides what the config claims. Your credentials decide what actually happens. Those are two separate systems and they can disagree, the way a door number never stops the wrong key from turning. With iam_role set at the root, a shell pointed at the wrong account fails at the AssumeRole call, before a module has even been downloaded.
# Right folder, wrong credentials in the shellexport AWS_PROFILE=staging-cicd live/prod/us-east-1/app/eksterragrunt apply
14:31:05 ERROR error assuming role arn:aws:iam::111122223333:role/terragrunt-deploy: operation error STS: AssumeRole, https response error StatusCode: 403, RequestID: 8f2b41d7-6c3a-4e91-b0f5-1d9c7a2e4b60, api error AccessDenied: User: arn:aws:iam::444455556666:user/ci-staging is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::111122223333:role/terragrunt-deploy14:31:05 ERROR Unable to determine underlying exit code, so Terragrunt will exit with error code 1
One File in the Wrong Place
Because the climb stops at the first match, the file nearest a unit wins. That is an ordinary feature and a real attack surface. Someone with commit access adds live/prod/us-east-1/app/account.hcl naming account 444455556666, titles the pull request "pin app account vars", and every unit under app/ now resolves to a different bucket, a different key and a different assumed role while still sitting in a folder called prod. State is a plain-text record of everything you manage, including passwords and keys the provider generated for you, so redirecting state is theft even when no infrastructure moves. The opposite mistake is quieter. A fragment placed higher than its proper level never overrides anything, it backstops a missing level, so a unit that should have failed loudly succeeds on somebody else's defaults. One find command catches both, because both are depth violations.
# Depth is counted from live/, which is depth 0.# Every account.hcl belongs at depth 2.find live -name account.hcl -printf '%d %p\n' | sort -n
2 live/prod/account.hcl2 live/security/account.hcl2 live/staging/account.hcl4 live/prod/us-east-1/app/account.hcl
#!/usr/bin/env bash# Fail the build if a level fragment sits at the wrong depth.# Depth from live/: account.hcl=2, region.hcl=3, env.hcl=4.# -printf is GNU find, which is what Debian and Ubuntu runners ship.set -euo pipefailfail=0check_depth() { # $1 = filename, $2 = required depthlocal depth path# Process substitution rather than a pipe: the loop has to run in THIS# shell, or fail=1 dies with the subshell and the build goes green.while read -r depth path; doif [ "$depth" != "$2" ]; thenecho "LAYOUT: $path is at depth $depth, expected $2" >&2fail=1fidone < <(find live -name "$1" -printf '%d %p\n')}check_depth account.hcl 2check_depth region.hcl 3check_depth env.hcl 4if [ "$(find live -name root.hcl | wc -l)" -ne 1 ]; thenecho "LAYOUT: expected exactly one live/root.hcl" >&2fail=1fiexit "$fail"
./ci/check-layout.sh; echo "exit=$?"
LAYOUT: live/prod/us-east-1/app/account.hcl is at depth 4, expected 2exit=1
Pair that check with review rules on the files themselves. CODEOWNERS is a file GitHub reads to demand sign-off from named teams before a change to matching paths can merge, and it borrows gitignore's matching rules. A pattern with no slash in it matches a file of that name at any depth, which is precisely what you want here. Write live/*/account.hcl instead and you protect the three fragments you already have while missing the new one an attacker drops four levels down.
# No leading slash: matches these filenames ANYWHERE in the repo,# including a new one somebody drops deep in the tree.root.hcl @acme/platform-securityaccount.hcl @acme/platform-securityregion.hcl @acme/platform-securityenv.hcl @acme/platform
Where You Stand Sets the Blast Radius
The tree also decides how much one command can touch. Terragrunt finds units by walking down from wherever you start it, so a run across the whole of live/ reaches all seven units in three accounts in one go, while starting inside live/staging/us-east-1 reaches two. Your current directory is the safety catch. In a pipeline you spell it out rather than trusting whatever the runner happened to check out.
#!/usr/bin/env bashset -euo pipefail# Scoped to one account and one region: 2 units, not 7.terragrunt run --all \--working-dir live/staging/us-east-1 \--non-interactive \--log-level info \-- plan# Narrow further to the units a pull request actually touched:# --queue-include-dir live/staging/us-east-1/app/vpc
There is no account boundary inside a run. Start at the top of the tree and Terragrunt walks into prod, staging and the security account in the same invocation, assuming a different role for each as it goes, because that is exactly what the layout told it to do. Add --non-interactive and one bad module version can move resources in three accounts before anyone reads a line of output. Pass --working-dir explicitly in CI, keep apply jobs pinned to a single account directory, and give the pipeline a role in that account only. Then a run that wanders is a run that fails.
Wire the depth check into the same job that runs your plan, and have that job print the rendered bucket, key and role for one unit per account. Two commands, a couple of seconds, and the whole class of silent-retarget bugs turns up as a red build instead of as a surprise in next quarter's CloudTrail review, where CloudTrail is the log of every API call made in the account and by then the call was made months ago.
Try this
Run find live -name '*.hcl' | sort on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: the folder is a label, not a lock. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.