⚠️ This is a demo / illustrative repository. Account IDs, domain names, org identifiers, and profile names are all placeholders. Do not commit real AWS account IDs, credentials, or production values. See Wiring guide below for what to replace before using this repo.
This repository is the companion reference implementation for the Declarative IaC with Terraform & Terragrunt blog series. It demonstrates a production-grade multi-account Terragrunt layout for AWS, paired with the hagzag/tf-modules versioned module library.
- The
account / region / environment / moduledirectory hierarchy that replaces Terraform workspaces - A
root.hclthat auto-generatesbackend.tfandprovider.tffor every module leaf usingpath_relative_to_includefor unique S3 state keys - Per-level HCL config files (
account.hcl,region.hcl,env.hcl) assembled at runtime byfind_in_parent_folders - Terragrunt built-in functions:
find_in_parent_folders,path_relative_to_include,get_terragrunt_dir,get_repo_root,read_terragrunt_config - OIDC-based keyless AWS auth (no static IAM access keys) for GitHub Actions and GitLab CI
- A hub-and-spoke multi-account IAM design: CI assumes a mgmt role; Terraform provider assumes target-account roles
- The
allowed_account_idsguard in the generated provider that prevents wrong-account applies - A
.bootstrap/one-time setup that createsTerraformAutomationRolein every account before CI exists
tf-live/
├── root.hcl # Generates backend.tf + provider.tf for every module leaf
├── proj.hcl # Project-wide constants: module repo URL, pinned tag, org name, tags
│
├── mgmt/ # Management account (IAM, OIDC providers, shared infra)
│ ├── account.hcl # account_id = "111111111111" (placeholder)
│ └── eu-west-1/
│ ├── region.hcl
│ └── global/
│ ├── env.hcl
│ ├── github-oidc/ → terragrunt.hcl # GitHub Actions OIDC provider + role
│ ├── gitlab-oidc/ → terragrunt.hcl # GitLab CI OIDC provider + role
│ ├── regional-info/ → terragrunt.hcl
│ └── role-validator/ → terragrunt.hcl
│
├── dev/ # Development account
│ ├── account.hcl # account_id = "222222222222" (placeholder)
│ └── eu-west-1/
│ ├── region.hcl
│ ├── dev/
│ │ ├── env.hcl
│ │ ├── regional-info/ → terragrunt.hcl
│ │ └── role-validator/ → terragrunt.hcl
│ └── playground/
│ ├── env.hcl
│ ├── regional-info/ → terragrunt.hcl
│ └── role-validator/ → terragrunt.hcl
│
├── prod/ # Production account
│ ├── account.hcl # account_id = "333333333333" (placeholder)
│ └── us-east-1/
│ ├── region.hcl
│ └── prod/
│ ├── env.hcl
│ ├── regional-info/ → terragrunt.hcl
│ └── role-validator/ → terragrunt.hcl
│
└── .bootstrap/ # One-time setup — run locally before CI exists
├── root.hcl # Uses SSO profiles directly (no role assumption yet)
├── accounts.hcl # Flat map of all accounts for bootstrap providers
└── cross-account-role/
├── README.md # Step-by-step bootstrap instructions
├── terragrunt.hcl # Generates per-account IAM role resources
├── main.tf # mgmt TerraformAutomationRole + SSO trust
└── variables.tf
Every module leaf contains a single terragrunt.hcl with include { path = find_in_parent_folders("root.hcl") }. When Terragrunt runs that module, root.hcl walks up the directory tree and reads:
locals {
region = read_terragrunt_config(find_in_parent_folders("region.hcl"))
env = read_terragrunt_config(find_in_parent_folders("env.hcl"))
proj = read_terragrunt_config(find_in_parent_folders("proj.hcl"))
account = read_terragrunt_config(find_in_parent_folders("account.hcl"))
}It then generates two files in the module's .terragrunt-cache/:
backend.tf — unique S3 key per module, derived from the directory path:
# For dev/eu-west-1/dev/regional-info/
key = "dev/eu-west-1/dev/regional-info/terraform.tfstate"provider.tf — AWS provider scoped to the correct account, with allowed_account_ids as a guard:
provider "aws" {
region = "eu-west-1"
allowed_account_ids = ["222222222222"]
assume_role {
role_arn = "arn:aws:iam::222222222222:role/TerraformAutomationRole"
session_name = "Terraform-222222222222"
}
}The allowed_account_ids field will cause Terraform to fail immediately if the assumed credentials belong to a different account — catching misconfigured CI matrix entries before any API calls.
| Function | What it returns | Where used |
|---|---|---|
find_in_parent_folders("x.hcl") |
Absolute path to the nearest x.hcl walking up the tree |
include blocks; read_terragrunt_config() calls |
path_relative_to_include() |
Path of the current module relative to the root include | S3 state key — makes every module's state path unique |
get_terragrunt_dir() |
Absolute path of the current terragrunt.hcl |
basename(get_terragrunt_dir()) → derive alias/env/region from dir name |
get_repo_root() |
Git repo root (absolute path) | Reference scripts or assets from any module depth |
read_terragrunt_config(path) |
Parsed HCL locals from another config file | Load hierarchy locals into root.hcl |
No long-lived AWS access keys anywhere in this repo or in CI.
GitHub Actions / GitLab CI
└── OIDC JWT → sts:AssumeRoleWithWebIdentity
└── mgmt account: TerraformAutomationRole
├── sts:AssumeRole → dev account: TerraformAutomationRole
└── sts:AssumeRole → prod account: TerraformAutomationRole
The OIDC providers and trust policies live in mgmt/eu-west-1/global/github-oidc/ and gitlab-oidc/. They're managed by Terragrunt like everything else — after the one-time bootstrap.
| Tool | Version | Notes |
|---|---|---|
| Terraform | >= 1.5 |
or OpenTofu >= 1.6 |
| Terragrunt | >= 0.54 |
uses run --all syntax; see note below |
| AWS CLI | >= 2.x |
with SSO configured |
Terragrunt CLI note: This repo uses
terragrunt run --all plan(v0.54+). Older versions usedterragrunt run-all plan. If you're on an older version, either upgrade or add--experiment cli-redesignas a bridge flag.
Before running anything, update these files with real values:
modules_repo = "https://github.com/hagzag/tf-modules" # already correct for the demo modules
modules_tag = "v1.0.3" # pin to a real tag
github_org = "your-actual-github-org"
gitlab_owner = "your-actual-gitlab-group"account_id = "111111111111" # → your real management account IDaccount_id = "222222222222" # → your real dev account IDaccount_id = "333333333333" # → your real prod account IDbucket = "your-terraform-state-bucket" # → your real S3 state bucket name
region = "eu-west-1" # → the region where the bucket livesmgmt = { profile = "your-mgmt-profile", region = "eu-west-1", account_id = "111111111111" }
dev = { profile = "your-dev-profile", region = "eu-west-1", account_id = "222222222222" }
prod = { profile = "your-prod-profile", region = "us-east-1", account_id = "333333333333" }Replace profile names with the AWS SSO profile names in your ~/.aws/config.
The bootstrap creates TerraformAutomationRole in every account and the S3 state bucket.
It runs with your SSO credentials directly — no role assumption yet because the roles
don't exist yet.
# Log in to AWS SSO
aws sso login --profile your-mgmt-profile
# Create the cross-account roles
cd .bootstrap/cross-account-role
TG_SKIP_MODULE=false terragrunt init
TG_SKIP_MODULE=false terragrunt plan
TG_SKIP_MODULE=false terragrunt applyAfter apply:
TerraformAutomationRoleexists inmgmt,dev, andprod- The mgmt role can
sts:AssumeRoleinto dev and prod - SSO AdministratorAccess roles are trusted to assume the mgmt role
# GitHub Actions OIDC
cd mgmt/eu-west-1/global/github-oidc
TG_SKIP_MODULE=false terragrunt apply
# GitLab CI OIDC (if using GitLab)
cd ../gitlab-oidc
TG_SKIP_MODULE=false terragrunt applyAfter bootstrap, everything runs through CI. See the blog posts for the full pipeline definitions:
- GitHub Actions → Part 4 — CI/CD Pipelines for Terragrunt: GitHub Actions
- GitLab CI → Part 5 — CI/CD Pipelines for Terragrunt: GitLab CI
# Plan a single module
cd dev/eu-west-1/dev/regional-info
terragrunt plan
# Plan everything in an environment
cd dev/eu-west-1/dev
terragrunt run --all plan
# Plan across all accounts
cd /path/to/repo
terragrunt run --all plan| Placeholder | File | What to replace with |
|---|---|---|
111111111111 |
mgmt/account.hcl, .bootstrap/accounts.hcl |
Management AWS account ID |
222222222222 |
dev/account.hcl, .bootstrap/accounts.hcl |
Dev AWS account ID |
333333333333 |
prod/account.hcl, .bootstrap/accounts.hcl |
Prod AWS account ID |
your-terraform-state-bucket |
root.hcl, .bootstrap/root.hcl |
Your S3 bucket name (must already exist) |
your-mgmt-profile |
.bootstrap/accounts.hcl |
AWS SSO profile name for mgmt |
your-dev-profile |
.bootstrap/accounts.hcl |
AWS SSO profile name for dev |
your-prod-profile |
.bootstrap/accounts.hcl |
AWS SSO profile name for prod |
example-org |
proj.hcl |
Your GitHub org name |
example-group |
proj.hcl |
Your GitLab group name |
example.com |
proj.hcl |
Your root domain |
This repo was sanitized from a production setup. The following were removed or replaced:
| Original | Replaced with | Reason |
|---|---|---|
| Real AWS account IDs | 111111111111 / 222222222222 / 333333333333 |
Account enumeration risk |
| Internal org/team names | example-org / example-group |
Customer privacy |
| Internal SSO profile names | your-*-profile |
Environment-specific |
| Real S3 bucket name | your-terraform-state-bucket |
Prevents accidental state writes |
| Internal app/secret names | removed | Not relevant to the pattern |
- Module library: hagzag/tf-modules — versioned Terraform modules consumed by this repo
- Blog series: Declarative IaC with Terraform & Terragrunt
- Bootstrap guide: .bootstrap/cross-account-role/README.md