NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
Go modules · #367 by repository stars
Last release today
09 Oct 2026
Ships on a steady schedule
a new release about every 8 days
Rarely documented
notes for 10 of the last 60 stable releases
Nothing withdrawn
no release was ever pulled
10 years old
1758 releases · first in 2016
Nothing published for this version
Nothing published for this version
Nothing published for this version
One column per quarter.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
This change does not break those promises. It is listed under breaking changes so you are aware of it, in case it affects your workflows.
This is the second release candidate for Terragrunt v1.2.
It completes the same eleven experiments as v1.2.0-rc1, and adds bug fixes found since then, most of them in the CAS, the catalog, the Provider Cache Server, and exclude blocks.
Future release candidates for v1.2.0 will include bug fixes related to these experiments or other urgent bug fixes as necessary, and documentation improvements.
Please try out this release candidate in lower environments and share your feedback in the associated GitHub discussion.
base64gzip() uses the current Go encoderGo 1.27 changed the compressed output of its gzip encoder. Terragrunt v1.1.5 kept base64gzip() on the older output and warned once per run that this was legacy behavior. base64gzip() now returns what the Go 1.27 encoder produces.
A resource that compares the encoded value can plan a replacement on the first run after upgrading. An aws_instance with user_data_base64 set from base64gzip() and user_data_replace_on_change set to true is one such resource.
Where the encoded value has to stay stable, call the experimental base64gzip_compat(), which returns the v1.1.3 output permanently. It is behind the base64gzip-compat experiment and may be renamed or removed:
terragrunt run --all --experiment base64gzip-compat -- planinputs = {
user_data_base64 = base64gzip_compat(file("${get_terragrunt_dir()}/user-data.sh"))
}This completes the legacy-base64gzip strict control.
Note
Within the 1.0 guarantees
The 1.0 guarantees make promises about how Terragrunt remains backwards compatible. This change does not break those promises. It is listed under breaking changes so you are aware of it, in case it affects your workflows.
base64gzip() still takes a string and returns valid gzipped base64 content that decompresses to the same value, but the encoded value itself changes. base64gzip_compat() keeps the old one.
RootAccess bucket policy statementWhen bootstrapping an S3 state bucket, Terragrunt attached a bucket policy statement with the Sid RootAccess that granted s3:* on the bucket and its objects to arn:aws:iam::<account-id>:root, an ARN that grants access to the AWS account as a whole rather than only to its root user. That statement has been removed. It widened reach to state files that routinely hold secrets, and the account already owns the bucket.
The skip_bucket_root_access config no longer has anything to skip, and is now deprecated. Terragrunt still accepts it, and warns about it when bootstrapping a backend whose config sets it. Enable the skip-bucket-root-access strict control to turn that warning into an error.
To grant the AWS account root user access to the state bucket, set the new enable_bucket_root_access config:
# root.hcl
remote_state {
# ... other args omitted for brevity ...
config = {
# ... other config omitted for brevity ...
enable_bucket_root_access = true
}
}Buckets that already have the statement keep it. The state backend docs cover how to remove it yourself.
Note
Within the 1.0 guarantees
The 1.0 guarantees make promises about how Terragrunt remains backwards compatible. This change does not break those promises. It is listed under breaking changes so you are aware of it, in case it affects your workflows.
The bucket policy Terragrunt writes is not part of the CLI, HCL, or output schemas the guarantees pin, and skip_bucket_root_access remains valid configuration. Removing the statement is a bug fix, and enable_bucket_root_access restores it. See Bugs in the guarantees for how a bug fix in 1.x can change your workflows.
terragrunt catalog output with --formatThe catalog TUI needs a terminal. The --format flag (env: TG_FORMAT) writes what the catalog discovers to standard output, so a script or an agent can read the catalog without one.
--format=jsonl writes one JSON object per catalog entry, following a published JSON schema:
terragrunt catalog --format=jsonl | jq -c '{kind, title, component_source}'--format=md writes a Markdown document with a section per entry:
terragrunt catalog --format=md > catalog.mdWithout --format, terragrunt catalog opens the TUI only when standard input and standard output are both terminals. Anywhere else it writes jsonl, so piping the command needs no flag:
terragrunt catalog | jq -c '{kind, title, component_source}'Terragrunt writes each entry as it discovers it. See Non-interactive catalog for the structure of each format and how streaming behaves.
Previously gated behind the catalog-format experiment, non-interactive catalog output no longer requires --experiment catalog-format.
Terragrunt writes CPU, heap, and goroutine profiles on request, so you can see where a slow run spends its time. Pass --profile-cpu, --profile-mem or --profile-goroutine with a path, or --profile-dir to collect all three into one directory under conventional names. Each flag has a matching TG_PROFILE_* environment variable.
terragrunt --profile-dir /tmp/profiles run --all -- planRead the result with go tool pprof. The profiles cover Terragrunt itself, not the OpenTofu/Terraform processes it runs.
Previously gated behind the profiling experiment, the profile flags no longer require --experiment profiling.
--discovery-boundary and (dir) filtersGraph filters search up to the Git repository root for dependents and follow dependencies wherever they point, so in a monorepo they can parse sibling environments a command never needed.
A (dir) operand in a graph filter stops traversal at that directory:
cd environments/staging
terragrunt find --filter '(.)...vpc'From the same directory, the --discovery-boundary flag (env: TG_DISCOVERY_BOUNDARY) applies one boundary to every --filter expression on the command:
terragrunt run --all --filter '...vpc' --discovery-boundary . -- planPreviously gated behind the bounded-discovery experiment, bounded discovery no longer requires --experiment bounded-discovery.
Terragrunt now publishes the read-only Terragrunt docs MCP server, which answers Terragrunt questions from the official docs, the CLI reference, a curated design-pattern library, and real example config. The server is public and unauthenticated. Results are pinned to a Terragrunt version, and docs pages can be read at any release tag from v0.80 onward.
For Claude Code:
claude mcp add -s user --transport http terragrunt-docs https://mcp.docs.terragrunt.com/mcpCursor and other MCP clients that read an mcp.json point at https://mcp.docs.terragrunt.com/mcp instead.
The server is in public beta. It has no availability guarantee and may change significantly.
See the install docs for the full setup.
An oci:// source downloads a module from an OCI Distribution registry, such as Amazon ECR, GitHub Container Registry, Azure Container Registry, Google Artifact Registry, or a self-hosted one. It works in a terraform block:
# terragrunt.hcl
terraform {
source = "oci://ghcr.io/acme/tofu-modules/vpc?tag=1.0.0"
}And in the unit and stack blocks of a terragrunt.stack.hcl:
# terragrunt.stack.hcl
unit "vpc" {
source = "oci://ghcr.io/acme/terragrunt-units/vpc?tag=1.0.0"
path = "vpc"
}Pin the artifact with tag or digest. Setting neither selects the latest tag, and a //subdir selector reaches a directory inside the module, unit, or stack. Credentials come from OpenTofu's CLI config, from ambient Docker config, and from credential helpers such as ecr-login, so one source string resolves the same way under both tofu and Terragrunt.
Previously gated behind the oci experiment, these sources no longer require --experiment oci. See OCI registries for the publishing contract and the full authentication order.
Terragrunt records the files that the built-in file functions read, so reading-based filters select the units that read them without a mark_as_read call:
filetemplatefilefilesetfileexistsfile* hash functions, such as filesha256A file read from inside a template counts too.
mark_as_read remains the way to record a file that only OpenTofu/Terraform reads, such as one passed to a module as an input, or one a run_cmd script reads.
The Content Addressable Store (CAS) now stores the files that generate blocks produce. Each unit's working directory gets a hard link to the stored copy, so every unit that includes this block shares one provider.tf on disk:
# root.hcl
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = "provider \"aws\" {}"
}Generated files are read-only by default, so an existing hook or script that edits a generated file in place fails with a permission error. Set mutable = true on that generate block to give each unit a writable file of its own:
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
mutable = true
contents = "provider \"aws\" {}"
}Passing --no-cas turns off the CAS for a run, and Terragrunt writes generated files as plain files:
terragrunt run --all --no-cas -- planSee Generate blocks and Immutable by default for details.
Previously gated behind the mutable-generate experiment, CAS storage for generated files no longer requires --experiment mutable-generate.
unit, stack, and dependency blocks with expansionAn expansion block declares a count or a for_each, and Terragrunt reads the block it sits in once per element. This unit block generates two units, at .terragrunt-stack/aurora/web and .terragrunt-stack/aurora/api:
# terragrunt.stack.hcl
unit "aurora" {
expansion {
for_each = toset(["web", "api"])
}
source = "../units/app"
path = "aurora/${each.key}"
values = {
role = each.key
}
}A stack block expands the same way, generating one stack per element.
An expanded dependency block produces one dependency per element, and inputs reads each one by its key:
# terragrunt.hcl
dependency "aurora" {
expansion {
for_each = toset(["web", "api"])
}
config_path = "../aurora-${each.key}"
}
inputs = {
web_id = dependency.aurora["web"].outputs.id
}unit and stack blocks also accept an enabled attribute. Setting it to false skips the component during stack generation:
# terragrunt.stack.hcl
unit "canary" {
enabled = false
source = "../units/app"
path = "canary"
}Adding an expansion block to an existing block, or shrinking one, changes the addresses of the components it produces. Read the expansion reference before changing one that has already been applied.
Previously gated behind the block-iteration experiment, expansion blocks and the enabled attribute no longer require --experiment block-iteration.
Terragrunt provisions the resource group, storage account, and blob container backing an azurerm state, and converges blob versioning and soft delete on both new and pre-existing accounts. It also deletes state blobs and containers, and migrates state within a storage account. Dependency outputs of Azure-backed units are read straight from the state blob, the same as S3 and GCS.
If you already pass --backend-bootstrap, Terragrunt now creates Azure resources it skipped before.
Previously gated behind the azure-backend experiment, these operations no longer require --experiment azure-backend. See State Backend for configuration keys and authentication.
The ARM API treats an unset minimum TLS version as TLS1_0, but Azure deprecated TLS1_0 and TLS1_1 in August 2025.
Terragrunt now provisions a new storage account with a minimum TLS version of TLS1_2. The minimum_tls_version option raises it to TLS1_3:
remote_state {
backend = "azurerm"
config = {
storage_account_name = "myterragruntstate"
container_name = "tfstate"
key = "${path_relative_to_include()}/tofu.tfstate"
resource_group_name = "tofu-rg"
use_azuread_auth = true
minimum_tls_version = "TLS1_3"
}
}TLS1_2 and TLS1_3 are the only accepted values. Terragrunt rejects the deprecated TLS1_0 and TLS1_1.
The setting applies only when Terragrunt creates the account. Terragrunt leaves an existing account's setting alone, so change it there with the Azure portal or CLI.
Terragrunt evaluates the built-in functions in configurations using its own copy of the OpenTofu implementations, which tracks OpenTofu 1.13.
These functions are now available:
assumeequalassumelistlengthassumelistlengthmaxassumelistlengthminassumemaplengthassumemaplengthmaxassumemaplengthminassumenotnullassumesetlengthassumesetlengthmaxassumesetlengthminassumestringprefixbase64gunzipcidrcontainsephemeralasnullissensitivetemplatestringurldecodeTerragrunt reads dependency outputs straight from the remote state object, without initializing each dependency to run tofu output or terraform output against it. This covers the S3, GCS, and Azure Storage (azurerm) backends.
When a direct read is unsupported, such as for a backend Terragrunt has no reader for, or fails on a permissions or network error, Terragrunt falls back to tofu/terraform output -json. The outputs are the same either way, so only the speedup is lost.
Pass --no-dependency-fetch-output-from-state (env: TG_NO_DEPENDENCY_FETCH_OUTPUT_FROM_STATE) to always load dependency outputs through tofu/terraform output -json.
Previously gated behind the dependency-fetch-output-from-state experiment, direct state reads no longer require --experiment dependency-fetch-output-from-state. Passing --dependency-fetch-output-from-state still works, and the dependency-fetch-output-from-state strict control turns its deprecation warning into an error.
The terraform block accepts a version attribute holding a version constraint for a tfr:// registry module. Terragrunt downloads the highest published version that satisfies the constraint, using the same syntax as the version argument on OpenTofu and Terraform module blocks:
terraform {
source = "tfr://registry.opentofu.org/terraform-aws-modules/vpc/aws"
version = "~> 3.3"
}See the terraform block reference for the full rules.
Previously gated behind the version-attribute experiment, version constraints for registry modules no longer require --experiment version-attribute.
An account regional namespace is a reserved subdivision of the S3 bucket namespace that only your account can create buckets in, so no one else can take or re-create those names. Bucket names in it end with your account ID, the region, and -an.
When a bucket name matches that convention, Terragrunt creates the bucket in the account regional namespace:
# root.hcl
remote_state {
backend = "s3"
config = {
bucket = "my-tofu-state-111122223333-us-east-1-an"
key = "${path_relative_to_include()}/tofu.tfstate"
region = "us-east-1"
}
}There is no setting to enable this. S3 accepts the -an suffix only for account regional buckets, so the name alone decides. accesslogging_bucket_name is read the same way. Buckets named any other way are created in the global namespace, and the namespace is left out of the request entirely, so S3-compatible object stores are unaffected.
A name that fits the convention but names a region other than the bucket's own fails immediately.
--no-dependency-outputsThe --no-dependency-outputs flag (env: TG_NO_DEPENDENCY_OUTPUTS) skips output resolution for every dependency block in a run, so Terragrunt does not call tofu output on dependencies that may not be applied yet:
terragrunt run --all --no-dependency-outputs -- validateWarning
Use this flag with commands that do not read dependency outputs, such as init and validate. While it is set, references to dependency outputs get no real value, so plan and apply can pass empty values to OpenTofu/Terraform in their place.
Previously gated behind the optional-dependency-outputs experiment, the flag no longer requires --experiment optional-dependency-outputs.
--no-hooksThe --no-hooks flag (env: TG_NO_HOOKS) skips every hook for a run: before_hook, after_hook, and error_hook blocks.
terragrunt run --no-hooks -- planPreviously gated behind the optional-hooks experiment, --no-hooks no longer requires --experiment optional-hooks.
S3 disables ACLs on new buckets by default, and a bucket with ACLs disabled rejects the ACL grant Terragrunt wrote to make an access logging bucket accept logs. AWS recommends a bucket policy over an ACL for this grant, and recommends keeping ACLs disabled in general.
Terragrunt now creates buckets with ACLs disabled and grants access log delivery through the logging bucket's policy instead, allowing s3:PutObject for the logging.s3.amazonaws.com service principal on behalf of buckets in the same AWS account:
remote_state {
backend = "s3"
config = {
bucket = "my-state-bucket"
key = "${path_relative_to_include()}/tofu.tfstate"
region = "us-east-1"
accesslogging_bucket_name = "my-logs-bucket"
}
}This only applies to a logging bucket Terragrunt creates. One that already exists keeps the permissions it has, whether that is the ACL grant from an earlier Terragrunt version or something you set up yourself, and Terragrunt neither reads nor writes its policy.
skip_accesslogging_bucket_policy opts out of that grant. skip_accesslogging_bucket_acl is deprecated and now has no effect: Terragrunt puts no ACL on the logging bucket, so there is nothing left for it to skip.
If you set skip_accesslogging_bucket_acl to work around an AccessControlListNotSupported failure on a bucket with ACLs disabled, drop it. The bucket policy covers that bucket, and the attribute now suppresses nothing. Set skip_accesslogging_bucket_policy only if you grant log delivery yourself. Terragrunt warns when the deprecated attribute is used, and the skip-accesslogging-bucket-acl strict control turns that warning into an error.
include blocks warn againTerragrunt stopped logging the deprecation warning for an include block without a label, so a configuration using one gave no hint that the bare-include strict control would reject it. The parser setup suppressed the warning on the shared control before any file was read, and the check that finds the bare include then had nothing left to log.
A run over a configuration with a bare include now logs the warning once:
WARN Using an `include` block without a label is deprecated. Please use the `include` block with a label instead. For more information, see https://docs.terragrunt.com/migrate/bare-include/
With --strict-mode or --strict-control bare-include, the run still fails with an error and logs no warning. See the bare include migration guide for how to label the block.
expansion works with autoinclude and stack dependenciesterragrunt stack generate failed with There is no variable named "each" when a unit or stack block declared both an expansion block and an autoinclude block. The error pointed at each.key in path, even when autoinclude never referenced each.
Generation now writes an autoinclude file for each element, and each.key, each.value, and count.index inside autoinclude resolve to that element:
# terragrunt.stack.hcl
unit "repo" {
source = "../units/repo"
path = "repo"
}
unit "environment" {
expansion {
for_each = toset(["dev", "prod"])
}
source = "../units/environment"
path = "environment/${each.key}"
autoinclude {
dependency "repo" {
config_path = unit.repo.path
}
inputs = {
environment = each.key
repository = dependency.repo.outputs.name
}
}
}The prod element gets this terragrunt.autoinclude.hcl:
dependency "repo" {
config_path = "../../repo"
}
inputs = {
environment = "prod"
repository = dependency.repo.outputs.name
}A dependency block inside autoinclude that declared its own expansion block failed generation with the same error. A unit whose terragrunt.autoinclude.hcl contained one also failed to parse. An expanded unit could not be referenced from the stack file at all, since unit.<name>.path skipped it.
Each element of an expanded unit or stack is now referenced as unit.<name>[key].path. The generated dependency block keeps its expansion block. Generation evaluates for_each or count in the stack file, where local.* and values.* are available, writes the result as a literal, and resolves config_path for each element. The generated unit expands the dependency when it is parsed:
# terragrunt.stack.hcl
locals {
regions = toset(["us-east-1", "us-west-1"])
}
unit "vpc" {
expansion {
for_each = local.regions
}
source = "../units/vpc"
path = "vpc/${each.key}"
values = {
region = each.key
}
}
unit "app" {
source = "../units/app"
path = "app"
autoinclude {
dependency "vpc" {
expansion {
for_each = local.regions
}
config_path = unit.vpc[each.key].path
mock_outputs = { vpc_id = "vpc-mock-${each.key}" }
}
inputs = {
vpc_ids = { for region, vpc in dependency.vpc : region => vpc.outputs.vpc_id }
}
}
}# .terragrunt-stack/app/terragrunt.autoinclude.hcl
dependency "vpc" {
expansion {
for_each = toset(["us-east-1", "us-west-1"])
}
config_path = {
us-east-1 = "../vpc/us-east-1"
us-west-1 = "../vpc/us-west-1"
}[each.key]
mock_outputs = { vpc_id = "vpc-mock-${each.key}" }
}
inputs = {
vpc_ids = { for region, vpc in dependency.vpc : region => vpc.outputs.vpc_id }
}A stack file that declares the same unit or stack label both with and without an expansion now fails to parse, because unit.<name> cannot refer to both.
Discovery failed the same way on a dependency whose config_path pointed at a stack directory containing an expanded unit. It dropped the dependency instead of reporting the error, so run --all did not wait for the units in that stack. The dependency now covers every element of the expanded unit.
After a fetch that brings in more than about a hundred commits, git starts git maintenance run --auto in the background and returns without waiting for it. That process kept writing commit graphs into the CAS git store for a few seconds after Terragrunt had moved on, outside the lock Terragrunt holds on that store entry, and into temporary clones Terragrunt was already deleting.
Every git fetch Terragrunt runs now turns automatic maintenance off, so the fetch is finished when it returns.
A source pinned to a tag, such as ?ref=v1.2.3, failed to clone through the CAS when the repository also had a branch whose name ends in the tag name, such as release/v1.2.3, and that branch had moved past the tag:
WARN central git store unavailable for https://github.com/acme/modules.git, falling back to temporary clone: object <release/v1.2.3 commit> not present in central git store after fetching v1.2.3 from https://github.com/acme/modules.git
WARN CAS processing failed for unit "service": failed to CAS clone "https://github.com/acme/modules.git": ... fatal: not a tree object
Terragrunt then downloaded the source without the CAS on every run. A unit generated from a stack with update_source_with_cas = true and a relative terraform.source kept that relative source, which pointed outside .terragrunt-stack and failed init.
Terragrunt now resolves a ref to the same commit git fetch downloads, so v1.2.3 resolves to the tag. When a branch and a tag share the exact name, the tag wins, as it does for git fetch.
A repository with a hand-edited git tree can list a file at a path git would never write, such as ../../x.tf or a path under a symbolic link in the same tree. The CAS wrote the file where the path pointed, which could be outside the module's download directory.
Terragrunt now checks each path before writing to it and refuses the tree when one escapes the download directory. A path that passes through a symbolic link already present in the download directory is refused too, because the link decides where the write lands.
update_source_with_cas = !false now counts as trueTerragrunt now evaluates the update_source_with_cas expression instead of reading it as a literal value.
.git suffixThe catalog named each clone directory after the clone URL without removing the .git suffix, so https://github.com/acme/terraform-aws-modules.git cloned into a directory called terraform-aws-modules.git. That name appeared in the log line reporting where the repository was cloned, and in the module paths the catalog shows when a repository has no remote to link to. Terragrunt now strips the suffix for HTTPS and SSH clone URLs, git:: sources, and URLs that carry a ref query parameter or a fragment.
When the catalog cloned a repository through the CAS, it linked each component to a path inside the temporary clone directory instead of to the repository. terragrunt catalog --format=jsonl showed that path in the url field:
{"kind":"module","title":"VPC","url":"/tmp/catalog-7USjxQ-804451847/infrastructure-modules/modules/vpc"}The catalog read the remote and branch from the clone's git metadata, which under the CAS describes the CAS store's own bare repository. That repository records no remote, and its branch is the store's default rather than the source's.
Terragrunt now takes the remote from the clone URL, and the branch from the ref the URL asks for or from the branch the remote's HEAD points at. Under --cas-offline the link uses HEAD, which GitHub and GitLab resolve to the default branch.
A catalog URL that selects a subdirectory, such as github.com/acme/catalog//modules, now loads through the CAS. The url and component_source of each component include that subdirectory.
exclude blocks no longer crash on null, unknown or sensitive stringsTerragrunt panicked when if, no_run or exclude_dependencies in an exclude block evaluated to a null, unknown or sensitive string, such as if = tostring(dependency.vpc.outputs.skip).
These strings now behave like the matching bool values:
if reports null value is not allowed.no_run or exclude_dependencies counts as unset.exclude block error.find and list honor if in exclude blocksRunning terragrunt find --queue-construct-as apply or terragrunt list --queue-construct-as apply dropped every unit whose exclude block listed apply, even when the block's if was false:
exclude {
if = false
actions = ["plan", "apply", "destroy"]
}The run queue for run --all apply kept such a unit, so the two commands disagreed about what would run. Both commands now drop a unit only when its exclude block has if = true and lists the action.
{} groups in glob patterns no longer crash TerragruntSome glob patterns with an empty or unclosed {} group crashed Terragrunt when it matched them, e.g. terragrunt find --filter '{./a{}'. Others silently failed to match, so {}a did not match a.
Terragrunt now refuses these patterns with an invalid pattern error. This covers filter queries, include_in_copy and exclude_from_copy in the terraform block, and .terragrunt-catalog-ignore files. A group with one empty option next to a non-empty one, such as main.tf{,.bak}, still works.
hcl validate reports dependencies on deleted unitsWhen a dependency block pointed at a deleted or moved unit, run --all failed, but find, list and hcl validate passed. hcl validate now fails for every enabled dependency block whose config_path has no unit or stack configuration, so you can catch the problem before a run.
run --all with a Git-based filter, such as --filter '[main...HEAD]', still fails when a dependency block points at a unit or stack that the diff deleted. The error now names the deleted path and the Git reference that still has it:
a dependency points at dep, which exists at main but was deleted or moved in the Git diff
The new missing-dependency-config tip also suggests running hcl validate. find and list behave as before.
hcl validate --inputs reads -var and -var-file arguments verbatimhcl validate --inputs applied shell quoting rules to each entry in extra_arguments before reading -var and -var-file from it. Those rules treat a backslash as an escape character, so on Windows a var file path such as "-var-file=${get_terragrunt_dir()}\\varfiles\\main.tfvars" lost its separators, and validation failed to open the file.
Terragrunt now reads each entry in arguments exactly as written, as the single argument it becomes on the OpenTofu/Terraform command line.
iam_roleWhen two units had the same terragrunt.hcl content, Terragrunt could assume the first unit's IAM role for both. This hit any iam_role that depends on the unit's directory, such as:
iam_role = "arn:aws:iam::123456789012:role/${basename(get_terragrunt_dir())}"With this config in a/ and b/, b assumed role/a instead of role/b. Terragrunt now evaluates iam_role in each unit's own directory, so get_terragrunt_dir(), find_in_parent_folders(), and similar functions return that unit's paths.
source inherited through include is marked as read from the unit's directoryWhen a terraform block with a relative source lived in an included config, find --reading and reading= filters resolved the path against the included file instead of the unit, which is where a run resolves it. The module's files went missing from the unit's reading list, so a change to the module did not select the unit:
# root.hcl
terraform {
source = "${path_relative_from_include()}/modules//foo"
}$ terragrunt find --json --reading
[{"type":"unit","path":"live/unit","reading":["root.hcl"]}]Terragrunt now resolves the source against the unit's directory, so modules/foo/main.tf appears in reading and reading-based filters select the unit the same way they do for an absolute source.
terragrunt info print --all writes JSON Lines and reports each unit's own download directoryinfo print --all wrote each unit's info indented over several lines, one object after another, and gave every unit the root's download_dir:
$ terragrunt info print --all
{
"config_path": "/example/live/db/terragrunt.hcl",
"download_dir": "/example/live/.terragrunt-cache",
"iam_role": "",
"terraform_binary": "tofu",
"terraform_command": "print",
"working_dir": "/example/live/.terragrunt-cache/EfNrjc2equLKYmOZbwT2qu1dO9c/ByrgT1vMBQjFneXYgAxchposVZ0"
}
{
"config_path": "/example/live/vpc/terragrunt.hcl",
"download_dir": "/example/live/.terragrunt-cache",
...
}A line-oriented reader could not take one entry at a time:
$ terragrunt info print --all | head -1 | jq .
jq: parse error: Unfinished JSON term at EOF at line 2, column 0The download_dir was wrong as well. run --all creates each unit's .terragrunt-cache next to that unit's configuration, so the directory reported here was not the one the unit runs against.
With --all, Terragrunt now writes one object per line, so the output is JSON Lines, and builds each unit's context the way run --all does:
$ terragrunt info print --all | jq -c '{config_path, download_dir}'
{"config_path":"/example/live/db/terragrunt.hcl","download_dir":"/example/live/db/.terragrunt-cache"}
{"config_path":"/example/live/vpc/terragrunt.hcl","download_dir":"/example/live/vpc/.terragrunt-cache"}Printing a single unit is unchanged: one indented object.
exclude blocks report an errorTerragrunt silently dropped an exclude block with an attribute of the wrong type, such as actions = "plan" where a list belongs, and ran the unit as if the block weren't there. The parse now fails with an error that names the file and the attribute:
exclude block in /live/unit/terragrunt.hcl: json: cannot unmarshal string into Go struct field ExcludeConfig.actions of type []string
Discovery doesn't fetch dependency outputs, so it can't evaluate an exclude block that reads one. Terragrunt still skips that block during discovery, and now logs a warning namin
Note truncated.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
This change does not break those promises. It is listed under breaking changes so you are aware of it, in case it affects your workflows.
This is the first release candidate for Terragrunt v1.2.
This release completes the following experiments:
block-iterationocibounded-discoverycatalog-formatmutable-generateoptional-dependency-outputsoptional-hooksazure-backendversion-attributeprofilingdependency-fetch-output-from-stateFuture release candidates for v1.2.0 will include bug fixes related to these experiments or other urgent bug fixes as necessary, and documentation improvements.
Please try out this release candidate in lower environments and share your feedback in the Associated GitHub discussion.
base64gzip() uses the Go 1.27 encoderGo 1.27 changed the compressed output of its gzip encoder. Terragrunt v1.1.5 kept base64gzip() on the older output and warned once per run that this was legacy behavior. base64gzip() now returns what the Go 1.27 encoder produces.
A resource that compares the encoded value can plan a replacement on the first run after upgrading. An aws_instance with user_data_base64 set from base64gzip() and user_data_replace_on_change set to true is one such resource.
Where the encoded value has to stay stable, call the experimental base64gzip_compat(), which returns the v1.1.3 output permanently. It is behind the base64gzip-compat experiment and may be renamed or removed:
terragrunt run --all --experiment base64gzip-compat -- planinputs = {
user_data_base64 = base64gzip_compat(file("${get_terragrunt_dir()}/user-data.sh"))
}This completes the legacy-base64gzip strict control.
Note
Within the 1.0 guarantees
The 1.0 guarantees make promises about how Terragrunt remains backwards compatible. This change does not break those promises. It is listed under breaking changes so you are aware of it, in case it affects your workflows.
base64gzip() still takes a string and returns valid gzipped base64 content that decompresses to the same value, but the encoded value itself changes. base64gzip_compat() keeps the old one.
RootAccess bucket policy statementWhen bootstrapping an S3 state bucket, Terragrunt attached a bucket policy statement with the Sid RootAccess that granted s3:* on the bucket and its objects to arn:aws:iam::<account-id>:root, an ARN that grants access to the AWS account as a whole rather than only to its root user. That statement has been removed. It widened reach to state files that routinely hold secrets, and the account already owns the bucket.
The skip_bucket_root_access config no longer has anything to skip, and is now deprecated. Terragrunt still accepts it, and warns about it when bootstrapping a backend whose config sets it. Enable the skip-bucket-root-access strict control to turn that warning into an error.
To grant the AWS account root user access to the state bucket, set the new enable_bucket_root_access config:
# root.hcl
remote_state {
# ... other args omitted for brevity ...
config = {
# ... other config omitted for brevity ...
enable_bucket_root_access = true
}
}Buckets that already have the statement keep it. The state backend docs cover how to remove it yourself.
Note
Within the 1.0 guarantees
The 1.0 guarantees make promises about how Terragrunt remains backwards compatible. This change does not break those promises. It is listed under breaking changes so you are aware of it, in case it affects your workflows.
The bucket policy Terragrunt writes is not part of the CLI, HCL, or output schemas the guarantees pin, and skip_bucket_root_access remains valid configuration. Removing the statement is a bug fix, and enable_bucket_root_access restores it. See Bugs in the guarantees for how a bug fix in 1.x can change your workflows.
terragrunt catalog output with --formatThe catalog TUI needs a terminal. The --format flag (env: TG_FORMAT) writes what the catalog discovers to standard output, so a script or an agent can read the catalog without one.
--format=jsonl writes one JSON object per catalog entry, following a published JSON schema:
terragrunt catalog --format=jsonl | jq -c '{kind, title, component_source}'--format=md writes a Markdown document with a section per entry:
terragrunt catalog --format=md > catalog.mdWithout --format, terragrunt catalog opens the TUI only when standard input and standard output are both terminals. Anywhere else it writes jsonl, so piping the command needs no flag:
terragrunt catalog | jq -c '{kind, title, component_source}'Terragrunt writes each entry as it discovers it. See Non-interactive catalog for the structure of each format and how streaming behaves.
Previously gated behind the catalog-format experiment, non-interactive catalog output no longer requires --experiment catalog-format.
Terragrunt writes CPU, heap, and goroutine profiles on request, so you can see where a slow run spends its time. Pass --profile-cpu, --profile-mem or --profile-goroutine with a path, or --profile-dir to collect all three into one directory under conventional names. Each flag has a matching TG_PROFILE_* environment variable.
terragrunt --profile-dir /tmp/profiles run --all -- planRead the result with go tool pprof. The profiles cover Terragrunt itself, not the OpenTofu/Terraform processes it runs.
Previously gated behind the profiling experiment, the profile flags no longer require --experiment profiling.
--discovery-boundary and (dir) filtersGraph filters search up to the Git repository root for dependents and follow dependencies wherever they point, so in a monorepo they can parse sibling environments a command never needed.
A (dir) operand in a graph filter stops traversal at that directory:
cd environments/staging
terragrunt find --filter '(.)...vpc'From the same directory, the --discovery-boundary flag (env: TG_DISCOVERY_BOUNDARY) applies one boundary to every --filter expression on the command:
terragrunt run --all --filter '...vpc' --discovery-boundary . -- planPreviously gated behind the bounded-discovery experiment, bounded discovery no longer requires --experiment bounded-discovery.
Terragrunt now publishes the read-only Terragrunt docs MCP server, which answers Terragrunt questions from the official docs, the CLI reference, a curated design-pattern library, and real example config. The server is public and unauthenticated. Results are pinned to a Terragrunt version, and docs pages can be read at any release tag from v0.80 onward.
For Claude Code:
claude mcp add -s user --transport http terragrunt-docs https://mcp.docs.terragrunt.com/mcpCursor and other MCP clients that read an mcp.json point at https://mcp.docs.terragrunt.com/mcp instead.
The server is in public beta. It has no availability guarantee and may change significantly.
See the install docs for the full setup.
An oci:// source downloads a module from an OCI Distribution registry, such as Amazon ECR, GitHub Container Registry, Azure Container Registry, Google Artifact Registry, or a self-hosted one. It works in a terraform block:
# terragrunt.hcl
terraform {
source = "oci://ghcr.io/acme/tofu-modules/vpc?tag=1.0.0"
}And in the unit and stack blocks of a terragrunt.stack.hcl:
# terragrunt.stack.hcl
unit "vpc" {
source = "oci://ghcr.io/acme/terragrunt-units/vpc?tag=1.0.0"
path = "vpc"
}Pin the artifact with tag or digest. Setting neither selects the latest tag, and a //subdir selector reaches a directory inside the module, unit, or stack. Credentials come from OpenTofu's CLI config, from ambient Docker config, and from credential helpers such as ecr-login, so one source string resolves the same way under both tofu and Terragrunt.
Previously gated behind the oci experiment, these sources no longer require --experiment oci. See OCI registries for the publishing contract and the full authentication order.
Terragrunt records the files that the built-in file functions read, so reading-based filters select the units that read them without a mark_as_read call:
filetemplatefilefilesetfileexistsfile* hash functions, such as filesha256A file read from inside a template counts too.
mark_as_read remains the way to record a file that only OpenTofu/Terraform reads, such as one passed to a module as an input, or one a run_cmd script reads.
The Content Addressable Store (CAS) now stores the files that generate blocks produce. Each unit's working directory gets a hard link to the stored copy, so every unit that includes this block shares one provider.tf on disk:
# root.hcl
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = "provider \"aws\" {}"
}Generated files are read-only by default, so an existing hook or script that edits a generated file in place fails with a permission error. Set mutable = true on that generate block to give each unit a writable file of its own:
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
mutable = true
contents = "provider \"aws\" {}"
}Passing --no-cas turns off the CAS for a run, and Terragrunt writes generated files as plain files:
terragrunt run --all --no-cas -- planSee Generate blocks and Immutable by default for details.
Previously gated behind the mutable-generate experiment, CAS storage for generated files no longer requires --experiment mutable-generate.
unit, stack, and dependency blocks with expansionAn expansion block declares a count or a for_each, and Terragrunt reads the block it sits in once per element. This unit block generates two units, at .terragrunt-stack/aurora/web and .terragrunt-stack/aurora/api:
# terragrunt.stack.hcl
unit "aurora" {
expansion {
for_each = toset(["web", "api"])
}
source = "../units/app"
path = "aurora/${each.key}"
values = {
role = each.key
}
}A stack block expands the same way, generating one stack per element.
An expanded dependency block produces one dependency per element, and inputs reads each one by its key:
# terragrunt.hcl
dependency "aurora" {
expansion {
for_each = toset(["web", "api"])
}
config_path = "../aurora-${each.key}"
}
inputs = {
web_id = dependency.aurora["web"].outputs.id
}unit and stack blocks also accept an enabled attribute. Setting it to false skips the component during stack generation:
# terragrunt.stack.hcl
unit "canary" {
enabled = false
source = "../units/app"
path = "canary"
}Adding an expansion block to an existing block, or shrinking one, changes the addresses of the components it produces. Read the expansion reference before changing one that has already been applied.
Previously gated behind the block-iteration experiment, expansion blocks and the enabled attribute no longer require --experiment block-iteration.
Terragrunt provisions the resource group, storage account, and blob container backing an azurerm state, and converges blob versioning and soft delete on both new and pre-existing accounts. It also deletes state blobs and containers, and migrates state within a storage account. Dependency outputs of Azure-backed units are read straight from the state blob, the same as S3 and GCS.
If you already pass --backend-bootstrap, Terragrunt now creates Azure resources it skipped before.
Previously gated behind the azure-backend experiment, these operations no longer require --experiment azure-backend. See State Backend for configuration keys and authentication.
The ARM API treats an unset minimum TLS version as TLS1_0, but Azure deprecated TLS1_0 and TLS1_1 in August 2025.
Terragrunt now provisions a new storage account with a minimum TLS version of TLS1_2. The minimum_tls_version option raises it to TLS1_3:
remote_state {
backend = "azurerm"
config = {
storage_account_name = "myterragruntstate"
container_name = "tfstate"
key = "${path_relative_to_include()}/tofu.tfstate"
resource_group_name = "tofu-rg"
use_azuread_auth = true
minimum_tls_version = "TLS1_3"
}
}TLS1_2 and TLS1_3 are the only accepted values. Terragrunt rejects the deprecated TLS1_0 and TLS1_1.
The setting applies only when Terragrunt creates the account. Terragrunt leaves an existing account's setting alone, so change it there with the Azure portal or CLI.
Terragrunt evaluates the built-in functions in configurations using its own copy of the OpenTofu implementations, which tracks OpenTofu 1.13.
These functions are now available:
assumeequalassumelistlengthassumelistlengthmaxassumelistlengthminassumemaplengthassumemaplengthmaxassumemaplengthminassumenotnullassumesetlengthassumesetlengthmaxassumesetlengthminassumestringprefixbase64gunzipcidrcontainsephemeralasnullissensitivetemplatestringurldecodeTerragrunt reads dependency outputs straight from the remote state object, without initializing each dependency to run tofu output or terraform output against it. This covers the S3, GCS, and Azure Storage (azurerm) backends.
When a direct read is unsupported, such as for a backend Terragrunt has no reader for, or fails on a permissions or network error, Terragrunt falls back to tofu/terraform output -json. The outputs are the same either way, so only the speedup is lost.
Pass --no-dependency-fetch-output-from-state (env: TG_NO_DEPENDENCY_FETCH_OUTPUT_FROM_STATE) to always load dependency outputs through tofu/terraform output -json.
Previously gated behind the dependency-fetch-output-from-state experiment, direct state reads no longer require --experiment dependency-fetch-output-from-state. Passing --dependency-fetch-output-from-state still works, and the dependency-fetch-output-from-state strict control turns its deprecation warning into an error.
The terraform block accepts a version attribute holding a version constraint for a tfr:// registry module. Terragrunt downloads the highest published version that satisfies the constraint, using the same syntax as the version argument on OpenTofu and Terraform module blocks:
terraform {
source = "tfr://registry.opentofu.org/terraform-aws-modules/vpc/aws"
version = "~> 3.3"
}See the terraform block reference for the full rules.
Previously gated behind the version-attribute experiment, version constraints for registry modules no longer require --experiment version-attribute.
An account regional namespace is a reserved subdivision of the S3 bucket namespace that only your account can create buckets in, so no one else can take or re-create those names. Bucket names in it end with your account ID, the region, and -an.
When a bucket name matches that convention, Terragrunt creates the bucket in the account regional namespace:
# root.hcl
remote_state {
backend = "s3"
config = {
bucket = "my-tofu-state-111122223333-us-east-1-an"
key = "${path_relative_to_include()}/tofu.tfstate"
region = "us-east-1"
}
}There is no setting to enable this. S3 accepts the -an suffix only for account regional buckets, so the name alone decides. accesslogging_bucket_name is read the same way. Buckets named any other way are created in the global namespace, and the namespace is left out of the request entirely, so S3-compatible object stores are unaffected.
A name that fits the convention but names a region other than the bucket's own fails immediately.
--no-dependency-outputsThe --no-dependency-outputs flag (env: TG_NO_DEPENDENCY_OUTPUTS) skips output resolution for every dependency block in a run, so Terragrunt does not call tofu output on dependencies that may not be applied yet:
terragrunt run --all --no-dependency-outputs -- validateWarning
Use this flag with commands that do not read dependency outputs, such as init and validate. While it is set, references to dependency outputs get no real value, so plan and apply can pass empty values to OpenTofu/Terraform in their place.
Previously gated behind the optional-dependency-outputs experiment, the flag no longer requires --experiment optional-dependency-outputs.
--no-hooksThe --no-hooks flag (env: TG_NO_HOOKS) skips every hook for a run: before_hook, after_hook, and error_hook blocks.
terragrunt run --no-hooks -- planPreviously gated behind the optional-hooks experiment, --no-hooks no longer requires --experiment optional-hooks.
S3 disables ACLs on new buckets by default, and a bucket with ACLs disabled rejects the ACL grant Terragrunt wrote to make an access logging bucket accept logs. AWS recommends a bucket policy over an ACL for this grant, and recommends keeping ACLs disabled in general.
Terragrunt now creates buckets with ACLs disabled and grants access log delivery through the logging bucket's policy instead, allowing s3:PutObject for the logging.s3.amazonaws.com service principal on behalf of buckets in the same AWS account:
remote_state {
backend = "s3"
config = {
bucket = "my-state-bucket"
key = "${path_relative_to_include()}/tofu.tfstate"
region = "us-east-1"
accesslogging_bucket_name = "my-logs-bucket"
}
}This only applies to a logging bucket Terragrunt creates. One that already exists keeps the permissions it has, whether that is the ACL grant from an earlier Terragrunt version or something you set up yourself, and Terragrunt neither reads nor writes its policy.
skip_accesslogging_bucket_policy opts out of that grant. skip_accesslogging_bucket_acl is deprecated and now has no effect: Terragrunt puts no ACL on the logging bucket, so there is nothing left for it to skip.
If you set skip_accesslogging_bucket_acl to work around an AccessControlListNotSupported failure on a bucket with ACLs disabled, drop it. The bucket policy covers that bucket, and the attribute now suppresses nothing. Set skip_accesslogging_bucket_policy only if you grant log delivery yourself. Terragrunt warns when the deprecated attribute is used, and the skip-accesslogging-bucket-acl strict control turns that warning into an error.
expansion works with autoinclude and stack dependenciesterragrunt stack generate failed with There is no variable named "each" when a unit or stack block declared both an expansion block and an autoinclude block. The error pointed at each.key in path, even when autoinclude never referenced each.
Generation now writes an autoinclude file for each element, and each.key, each.value, and count.index inside autoinclude resolve to that element:
# terragrunt.stack.hcl
unit "repo" {
source = "../units/repo"
path = "repo"
}
unit "environment" {
expansion {
for_each = toset(["dev", "prod"])
}
source = "../units/environment"
path = "environment/${each.key}"
autoinclude {
dependency "repo" {
config_path = unit.repo.path
}
inputs = {
environment = each.key
repository = dependency.repo.outputs.name
}
}
}The prod element gets this terragrunt.autoinclude.hcl:
dependency "repo" {
config_path = "../../repo"
}
inputs = {
environment = "prod"
repository = dependency.repo.outputs.name
}A dependency block inside autoinclude that declared its own expansion block failed generation with the same error. A unit whose terragrunt.autoinclude.hcl contained one also failed to parse. An expanded unit could not be referenced from the stack file at all, since unit.<name>.path skipped it.
Each element of an expanded unit or stack is now referenced as unit.<name>[key].path. The generated dependency block keeps its expansion block. Generation evaluates for_each or count in the stack file, where local.* and values.* are available, writes the result as a literal, and resolves config_path for each element. The generated unit expands the dependency when it is parsed:
# terragrunt.stack.hcl
locals {
regions = toset(["us-east-1", "us-west-1"])
}
unit "vpc" {
expansion {
for_each = local.regions
}
source = "../units/vpc"
path = "vpc/${each.key}"
values = {
region = each.key
}
}
unit "app" {
source = "../units/app"
path = "app"
autoinclude {
dependency "vpc" {
expansion {
for_each = local.regions
}
config_path = unit.vpc[each.key].path
mock_outputs = { vpc_id = "vpc-mock-${each.key}" }
}
inputs = {
vpc_ids = { for region, vpc in dependency.vpc : region => vpc.outputs.vpc_id }
}
}
}# .terragrunt-stack/app/terragrunt.autoinclude.hcl
dependency "vpc" {
expansion {
for_each = toset(["us-east-1", "us-west-1"])
}
config_path = {
us-east-1 = "../vpc/us-east-1"
us-west-1 = "../vpc/us-west-1"
}[each.key]
mock_outputs = { vpc_id = "vpc-mock-${each.key}" }
}
inputs = {
vpc_ids = { for region, vpc in dependency.vpc : region => vpc.outputs.vpc_id }
}A stack file that declares the same unit or stack label both with and without an expansion now fails to parse, because unit.<name> cannot refer to both.
Discovery failed the same way on a dependency whose config_path pointed at a stack directory containing an expanded unit. It dropped the dependency instead of reporting the error, so run --all did not wait for the units in that stack. The dependency now covers every element of the expanded unit.
{} groups in glob patterns no longer crash TerragruntSome glob patterns with an empty or unclosed {} group crashed Terragrunt when it matched them, e.g. terragrunt find --filter '{./a{}'. Others silently failed to match, so {}a did not match a.
Terragrunt now refuses these patterns with an invalid pattern error. This covers filter queries, include_in_copy and exclude_from_copy in the terraform block, and .terragrunt-catalog-ignore files. A group with one empty option next to a non-empty one, such as main.tf{,.bak}, still works.
hcl validate --inputs reads -var and -var-file arguments verbatimhcl validate --inputs applied shell quoting rules to each entry in extra_arguments before reading -var and -var-file from it. Those rules treat a backslash as an escape character, so on Windows a var file path such as "-var-file=${get_terragrunt_dir()}\\varfiles\\main.tfvars" lost its separators, and validation failed to open the file.
Terragrunt now reads each entry in arguments exactly as written, as the single argument it becomes on the OpenTofu/Terraform command line.
iam_roleWhen two units had the same terragrunt.hcl content, Terragrunt could assume the first unit's IAM role for both. This hit any iam_role that depends on the unit's directory, such as:
iam_role = "arn:aws:iam::123456789012:role/${basename(get_terragrunt_dir())}"With this config in a/ and b/, b assumed role/a instead of role/b. Terragrunt now evaluates iam_role in each unit's own directory, so get_terragrunt_dir(), find_in_parent_folders(), and similar functions return that unit's paths.
terragrunt info print --all writes JSON Lines and reports each unit's own download directoryinfo print --all wrote each unit's info indented over several lines, one object after another, and gave every unit the root's download_dir:
$ terragrunt info print --all
{
"config_path": "/example/live/db/terragrunt.hcl",
"download_dir": "/example/live/.terragrunt-cache",
"iam_role": "",
"terraform_binary": "tofu",
"terraform_command": "print",
"working_dir": "/example/live/.terragrunt-cache/EfNrjc2equLKYmOZbwT2qu1dO9c/ByrgT1vMBQjFneXYgAxchposVZ0"
}
{
"config_path": "/example/live/vpc/terragrunt.hcl",
"download_dir": "/example/live/.terragrunt-cache",
...
}A line-oriented reader could not take one entry at a time:
$ terragrunt info print --all | head -1 | jq .
jq: parse error: Unfinished JSON term at EOF at line 2, column 0The download_dir was wrong as well. run --all creates each unit's .terragrunt-cache next to that unit's configuration, so the directory reported here was not the one the unit runs against.
With --all, Terragrunt now writes one object per line, so the output is JSON Lines, and builds each unit's context the way run --all does:
$ terragrunt info print --all | jq -c '{config_path, download_dir}'
{"config_path":"/example/live/db/terragrunt.hcl","download_dir":"/example/live/db/.terragrunt-cache"}
{"config_path":"/example/live/vpc/terragrunt.hcl","download_dir":"/example/live/vpc/.terragrunt-cache"}Printing a single unit is unchanged: one indented object.
exclude blocks report an errorTerragrunt silently dropped an exclude block with an attribute of the wrong type, such as actions = "plan" where a list belongs, and ran the unit as if the block weren't there. The parse now fails with an error that names the file and the attribute:
exclude block in /live/unit/terragrunt.hcl: json: cannot unmarshal string into Go struct field ExcludeConfig.actions of type []string
Discovery doesn't fetch dependency outputs, so it can't evaluate an exclude block that reads one. Terragrunt still skips that block during discovery, and now logs a warning naming the file.
A dependency whose config_path pointed at a stack directory also depended on the units and stacks in that stack set to enabled = false. Stack generation never writes a disabled unit, so run --all failed on the missing directory:
You attempted to run terragrunt in a folder that does not contain a terragrunt.hcl file. Please add a terragrunt.hcl file and try again.
find --dependencies and dag graph listed the same missing path as a dependency.
The dependency now covers only enabled units. The units of a disabled stack are left out, including a tree generated before the stack was disabled.
--discovery-boundarystack generate, stack run, and stack output scanned the whole working directory for stack files and ignored --discovery-boundary. In a monorepo with a catalog next to live infrastructure, a catalog stack referencing files that exist only in the live tree failed the command, even though the command never asked for that stack.
The boundary now applies to these commands, including an inline (dir) operand, and it holds when a Git expression such as [main...HEAD] generates stacks for both compared commits. This works from the repository root:
terragrunt stack run plan --filter '(./live/)...[main...HEAD]'Terragrunt skips the catalog units outside ./live. It still scans the whole working directory when a positive filter has no dependent-side boundary and --discovery-boundary is unset, or when the boundaries fall in separate directories.
Dependent discovery had the same gap and parsed units outside the dependent-side boundary. It now starts the search for dependents at that boundary, which can be a directory inside the working directory.
In a Git expression, a relative boundary resolves against the repository root like any other path in the expression. Changed units outside a dependent-side boundary are ignored, and a boundary that exists in neither compared commit is an error. A dependency-side boundary only limits dependency traversal.
--auth-provider-cmd and --queue-construct-as reject unquoted shell operatorsTerragrunt splits --auth-provider-cmd and --queue-construct-as values into words without running a shell. An unquoted shell operator such as |, ;, &&, or > used to end the value, and Terragrunt used only the words before it, so --auth-provider-cmd 'get-creds | jq .creds' ran get-creds on its own.
A value with an unquoted shell operator is now an error. Quote the operator to pass it as part of an argument. To run a pipeline as the auth provider, put it in a script and pass the script.
mcp-command — Serve Terragrunt operations to AI agentsThe new mcp-command experiment adds the mcp command, which serves Terragrunt operations to AI agents over the Model Context Protocol.
An agent can ask which units exist, how they depend on each other, whether configurations pass validation, what order the units run in, what a unit's applied outputs are, and more.
Point an MCP client at the Terragrunt binary and make sure that it enables the experiment:
// .mcp.json
{
"mcpServers": {
"terragrunt": {
"command": "terragrunt",
"args": ["mcp"],
"env": {
"TG_EXPERIMENT": "mcp-command"
}
}
}
}By default, the server refuses to start any subprocess (e.g. tofu, terraform, git, or a run_cmd program). Wherever Terragrunt would have started one, the result substitutes a stand-in for its output, such as mock_outputs for a dependency output that tofu output -json would have fetched, and lists each substitution in a degraded field.
Pass --allow=exec to let the server run the tofu, terraform, and git that Terragrunt starts on its own during a run. A program a configuration names, through run_cmd(), a before_hook, or --auth-provider-cmd, is still refused, including a tofu, terraform, or git the configuration names for itself, so pointing the server at a repository does not hand it those programs. Allow the commands you want with --allow-cmd, a pattern matched against the program and each of its arguments (e.g. --allow-cmd='jq **'), or let the read-only tools ask: when discover, render_config, validate, or run_order meets a refused program, it sends the client an elicitation naming it, which the client usually shows the person operating the agent, and runs again with whatever they accept. plan, apply, and destroy never ask, since answering would mean running them a second time.
The remaining capabilities are denied the same way, each granted on its own:
--allow=http lets Terragrunt make HTTP requests on its own.
This includes downloading a unit's remote terraform { source }, fetching a stack's sources, reaching a cloud API to assume a role or read a bucket, and reading remote state directly from blob stores.
--allow=sops lets it decrypt SOPS-encrypted files.
Unless it is granted, sops_decrypt_file fails rather than handing an agent the cleartext of your secrets.
--allow=env passes the server's environment variables to configurations and the commands the tools run.
Unless it is granted, tool calls start from an empty environment, so get_env() returns its default. The server also clears its own environment variables and points HOME at an empty directory, so cloud SDKs, the SOPS decrypter, and git commands Terragrunt runs will find no credentials in environment variables or your home directory.
Granting a capability only changes the capabilities of Terragrunt. A process started under --allow=exec can still reach out on the network on its own, so tofu init will download providers whether or not --allow=http was passed to allow Terragrunt to make network requests. The directory the server is launched in is its root. A tool call targeting a directory outside it is refused, and graph traversal is bounded there too, so a filter following dependencies or dependents cannot bring back a unit from a tree the server was never pointed at. That bounds what the server acts on, not what a configuration can read: an HCL function such as file() reads the real disk wherever it points.
You can grant multiple capabilities at once:
// .mcp.json
{
"mcpServers": {
"terragrunt": {
"command": "terragrunt",
"args": ["mcp", "--allow=exec", "--allow=http"],
"env": {
"TG_EXPERIMENT": "mcp-command"
}
}
}
}A separate flag, --dangerously-allow-apply, adds apply and destroy tools on top of --allow=exec. Without it neither tool is registered, so a client is never told they exist, and the server won't ever run apply or destroy on behalf of a client.
With it, the tool calls return an elicitation (the protocol's way for a server to ask the client's user a question) naming the units that would be run, and the run starts only once the person operating the client accepts it. A decline ends the tool call, and a client with no way to ask anyone is refused. The approval names the units, not the changes: nothing is planned to build that list, since a plan costs a full run that the acceptance then repeats. Call the plan tool first if you want the changes in front of you before accepting.
Only grant this capability on infrastructure you are willing to lose.
Passing it without --allow=exec is refused at startup, since applying means running OpenTofu/Terraform. Launch the server in the environment directory you are willing to have changed rather than at the repository root, because that directory is as far as any tool call can reach:
// .mcp.json
{
"mcpServers": {
"terragrunt-throwaway": {
"command": "terragrunt",
"args": [
"mcp",
"--working-dir", "/path/to/dev",
"--allow=exec",
"--dangerously-allow-apply"
],
"env": {
"TG_EXPERIMENT": "mcp-command"
}
}
}
}Note truncated.
Git filters find nested units on Windows again
On Windows, find, list and browse returned nothing for a Git-based filter such as --filter '[main...HEAD]' when the diff changed only unit configurations in nested directories, like nested\path\terragrunt.hcl.
The worktree optimization in v1.1.5 checks out only the directories of changed units for these commands. On Windows, it spelled those directories with \ separators and looked them up in Git's file listing, which uses /. None matched, so it checked out nothing. Terragrunt now uses / separators for those directories on every platform.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Current flag names take precedence over deprecated ones
duplicate-dependency-labels also catches a shared config_pathTwo dependency blocks with different labels can point at the same config_path. Both parse, so the same unit is declared twice, and the two blocks drift apart as soon as one gains a mock_outputs or skip_outputs the other lacks:
dependency "vpc" {
config_path = "../vpc"
}
dependency "network" {
config_path = "../vpc"
}Terragrunt now warns when it finds this, alongside the existing warning for two blocks sharing a label. With the duplicate-dependency-labels strict control enabled, the warning becomes an error naming both addresses and the path they share:
/path/to/terragrunt.hcl: dependencies vpc and network both point at ../vpc; declare that dependency once and reference it under one name
run --all asked the remote what a source resolved to once per unit, so a hundred units sharing one module made a hundred requests. Each of them then read the same commit out of the store for itself.
Units that resolve the same source at the same time now share one probe, and units that need the same Git commit share the work of reading it into the CAS.
Measured over 100 units pointing at one Git module, counting the Git commands a run spawns:
| 100 units, one shared module | Before | After |
|---|---|---|
First run: git ls-remote |
100 | 1 |
| First run: reading the commit into the store | 202 | 4 |
| First run: Git commands in total | 304 | 7 |
| Later run, source on a branch | 100 | 1 |
| Later run, source on a version tag | 100 | 0 |
The last row needs the offline-cas experiment described below; the rest apply to every run. Against a local Git server the first run went from roughly 5 seconds to 0.3, and a later run from 1 second to 0.1. A real remote makes each avoided ls-remote worth more, since it costs a network round trip rather than a local process.
The new offline-cas experiment goes a step further and has the CAS record each probe answer in the store, so a later run can skip the request. How long it trusts an answer depends on the source:
The experiment also unlocks three flags that change how the recorded answers are used:
--cas-offline never contacts a remote. Sources come from the local store and the recorded answers, and anything missing is an error rather than a fetch.--cas-refresh ignores the recorded answers for one run and asks every remote again.--cas-probe-ttl trusts a changeable source's answer for a duration you choose, such as 10m.See Recorded probes and the offline-cas experiment.
The first time Terragrunt stores a repository in the Content Addressable Store (CAS), it copies the content of every file out of the clone. It used to launch a separate git process for each one, and on repositories with many files those launches dominated the time.
Terragrunt now reads a repository's content through a single long-lived git process, and stores several files at a time.
In benchmarks on an Apple M3 Max:
| files | before | after | change |
|---|---|---|---|
| 200 | 2.16s | 0.38s | -83% |
| 1,000 | 10.41s | 0.74s | -93% |
| 3,000 | 34.25s | 1.69s | -95% |
The saving grows with the number of files.
This applies when the CAS does not already hold the content, such as the first use of a new module version or a run against an empty store. Downloads that the CAS can already serve skipped this work before and are unchanged.
The CAS no longer writes a lock file beside each object it stores. A store had one lock file for every file and every directory listing it cached, so ~/.cache/terragrunt/cas held roughly twice as many entries as the cached content needed. Lock files already written stay where they are; deleting the store while no Terragrunt process is running against it reclaims them, and the store rebuilds without them.
Terragrunt preserves a couple of files from a repository's .git directory when it materializes a Git source, and which files those are depends on the command. Those files used to be folded into the stored entry for the commit, so the first command to fetch a commit decided what every later command received from it: a commit first cached by stack generate, which asks for none of those files, left a later run against the same commit without them. Each file is now recorded against the commit on its own, and a command receives exactly the files it asked for whether the commit was already cached or not.
A source pinned to a full commit SHA now asks the remote for that commit alone, one commit deep, instead of fetching every branch and tag with full history. Remotes that will not serve a commit by name, such as an older or locked-down server, still get the full fetch, so pinning keeps working everywhere. Where the remote does serve it, the first fetch of a large repository transfers the pinned commit and nothing else.
The numbers below come from micro-benchmarks run against a git server on the same machine. The fixture is a 500-commit history whose pinned commit sits 100 commits behind the tip.
| Measurement | Before | After | Change |
|---|---|---|---|
| Git objects kept after fetching the pinned commit | 543 | 43 | 92% fewer |
| Time to fetch the pinned commit | 575ms | 482ms | 16% faster |
Against a real remote the pinned fetch saves more than the table shows, since the objects it no longer asks for would also have to cross the network.
A filter with ... before its target, such as ...vpc, finds dependents by walking the directory tree around the target and parsing each configuration it passes to see whether it depends on the target. That walk parsed every configuration from scratch, even one Terragrunt had earlier in the same command, and it runs again from each dependent it finds. On a large repository, one query could read and parse the same unrelated unit once per dependent it selected.
Terragrunt now reuses a configuration it has already parsed, so each unit is read from disk about once per query.
In benchmarks on an Apple M3 Max, querying the dependents of a unit from its own directory, where every other unit depends on it:
| units | before | after |
|---|---|---|
| 10 | 15.3ms | 10.0ms |
| 50 | 235ms | 150ms |
| 200 | 3.19s | 2.09s |
From the repository root, where the walk only has to rule out the units that do not depend on the target, the same query over a 1,024-unit repository went from 250ms to 131ms.
Terragrunt frequently does a lot of small file operations at once: copying a module into its working directory, storing a repository in the Content Addressable Store (CAS), and materializing one back out. How many it ran at once scaled with the number of vCPUs seen by the Terragrunt process or the --parallelism flag if configured.
Terragrunt now picks that number by probing the filesystem it is about to write to to guess how much throughput it can handle to improve performance.
The gain is largest where the filesystem is much faster or slower than Terragrunt would expect, just scaling off vCPUs.
Materializing a 3,000 file repository on a 2 vCPU runner:
| filesystem | before | after | change |
|---|---|---|---|
| ext4 | 40.8ms | 24.4ms | -40% |
| btrfs | 48.7ms | 34.2ms | -30% |
| overlayfs | 71.5ms | 56.4ms | -21% |
On a 16 vCPU machine, storing that repository for the first time is 19% faster on ext4 and 20% faster on btrfs.
terragrunt hcl fmt now formats at most 8 files at once by default, which measured about 14% faster than one worker per CPU on a 16 core machine.
Runs with the fast-copy strict control enabled also copy module directories faster on macOS, by around 60% in benchmarks on an Apple M3 Max.
A Git-based filter, such as --filter '[main...HEAD]', generates a worktrees to be able to run tofu in states that aren't reflected in the current worktree (e.g. when a unit is deleted, Terragrunt has to run a plan -destroy or apply -destroy in the main worktree, not the HEAD worktree in the earlier example).
As a conditional optimization, Terragrunt now reads the Git diff first and generates worktrees only when on-disk worktrees are necessary downstream.
For commands like find, list or browse worktree generation can be skipped more aggressively, and even more performance improvements were made there.
On a repository with 15,000 tracked files, terragrunt find --filter '[HEAD~1...HEAD]' went from 4.7s to 0.4s on an M3 Max machine.
run --allWhen you set --json-out-dir, Terragrunt saves a JSON plan for every unit it runs. It used to build each of those documents in memory in full before writing any of it to disk, so a unit with a 64 MB plan needed roughly 168 MB to save it, and every unit running in parallel needed its own. Terragrunt now writes the document as it arrives. That same plan needs about 300 KB, roughly 550x less, and saving it finishes about 18% faster.
Two other places held on to more than they needed. During run --all plan, Terragrunt kept every unit's error output until the run finished so it could check it for a single message at the end, and it now checks that as the output streams. Responses from a provider registry were read twice on the way in, and are now read once, which uses about 19% less memory per request.
JSON plans are also replaced atomically now. A run that fails part way through leaves the previous file in place instead of truncating it.
mutable = true sources are cloned instead of copiedA source marked mutable = true needs a file of its own, because a hard link would hand out the store's read-only copy. Terragrunt now asks the filesystem for a copy-on-write clone of the stored file and copies only where the filesystem has none to give. APFS, btrfs, and XFS volumes with reflink support have one.
A cloned target shares the stored content until you write to it, so it occupies disk space only for the parts you change. On those volumes, marking a source mutable in every unit costs disk space only for what each unit edits.
These micro-benchmarks time materializing an editable tree on APFS on an M3 Max, once copied as in earlier releases and once cloned.
| Tree | Before (copied) | After (cloned) | Change |
|---|---|---|---|
| 500 files, 7.3 MiB, most around 2 KiB | 71ms | 81ms | 14% slower |
| 120 files, 40 MiB, 20 of them 2 MiB each | 146ms | 23ms | 84% faster |
A clone takes about the same time for a file of any size, while a copy takes longer the bigger the file. A tree of small files takes about 10ms longer to materialize, and a tree with large files materializes about six times faster.
Every parse used to record the files it read. Part of that record is the content of each local module a unit sources, so Terragrunt walked those module directories once per unit, on every command, whether or not anything would look at the result.
Only four things consult the record: reading-based filter expressions, the --queue-include-units-reading flag, find --reading, and the file tree in terragrunt browse. Terragrunt now keeps it for those and skips the module walk everywhere else.
Benchmarks on an Apple M3 Max, across 1,000 units that all source the same local module:
| files in the module | find --dependencies |
render --all |
|---|---|---|
| 50 | 148 ms → 135 ms | 352 ms → 259 ms |
| 150 | 180 ms → 135 ms | 430 ms → 263 ms |
| 400 | 268 ms → 137 ms | 631 ms → 270 ms |
render --all performs the same full parse of each unit that run --all performs before it invokes OpenTofu, so a run over units with large local modules saves comparable time before the first plan starts.
The saving grows with the size of the local modules a repository sources, and the new times hold steady as those modules grow. Commands that do ask about reads behave as they did before.
gitget_repo_root(), get_path_from_repo_root(), get_path_to_repo_root(), the runner, and discovery all need the root of the enclosing repository. Terragrunt used to ask Git for it by running git rev-parse --show-toplevel, and starting that process cost far more than producing the answer did.
Terragrunt now finds the root itself, by looking for a .git entry in the working directory and each directory above it. Linked worktrees and submodules resolve the way they did before.
In benchmarks on an Apple M3 Max, resolving one root, where depth is how many directories separate the starting point from the root:
| depth | before | after |
|---|---|---|
| 1 | 5.29ms | 14µs |
| 5 | 5.20ms | 24µs |
| 10 | 5.25ms | 38µs |
Because Terragrunt no longer asks Git, some of Git's own settings for locating a repository stop applying. GIT_CEILING_DIRECTORIES still stops the search where it did. GIT_DIR, GIT_WORK_TREE and core.worktree are ignored, and the safe.directory ownership check is not applied, so get_repo_root() now answers in a repository owned by another user where Git refuses. A path inside a bare repository still reports that there is no repository. This is assumed to be more expected from the perspective of a Terragrunt user, and usage of git rev-parse --show-toplevel from a run_cmd is still available otherwise. If this impacts your workflows, please open a bug report, and maintainers are happy to work with you on this.
Terragrunt used to generate most files by opening the destination and writing into it, so the file spent time on disk half-written, and a run that failed partway through left a truncated one behind.
These now go to a temporary file that replaces the destination once it is complete:
generate blocksrender --write--report-file--debug.terraform.lock.hclbackend commands no longer fail on unapplied dependenciesbackend bootstrap, backend migrate and backend delete used to read the whole configuration of every unit they touched, which meant fetching the outputs of every dependency block. Declaring a dependency on a unit you had not applied yet was enough to stop them with the "detected no outputs" error, even when nothing in remote_state read that dependency.
These commands now read only the remote_state block and the terraform block's source. They never fetch dependency outputs. A remote_state that does read a dependency output still resolves it, and still reports missing outputs when the dependency has not been applied.
base64gzip() returns the v1.1.3 bytes againTerragrunt v1.1.4 was built with Go 1.27, which changed the compressed bytes produced by base64gzip(). The bytes decompress to the same content, but a resource that compares the encoded value, such as an EC2 instance with user_data_base64 and user_data_replace_on_change = true, planned a replacement after the upgrade.
base64gzip() now returns the bytes it returned in v1.1.3 and earlier, so upgrading plans no change. Terragrunt warns once per run that this is legacy behavior. If you already applied the v1.1.4 output, every plan shows the encoded value changing back until you apply it or enable the strict control below, and a resource that depends on stability of base64gzip bytes is replaced by that apply.
Terragrunt 1.2 will switch base64gzip() to the new encoder by default. The new base64gzip_compat() function, behind the base64gzip-compat experiment, returns the v1.1.3 bytes permanently (assuming the experiment eventually stabilizes), so call it where the encoded value must stay stable across upgrades. This function may be removed in a future release.
To keep the current Go encoder's output now and silence the warning, enable the new legacy-base64gzip strict control:
terragrunt run plan --strict-control legacy-base64gzipWhen something removes a file from the Content Addressable Store (CAS) that a cached source still needs, Terragrunt now downloads that source again and restores what is missing, then carries on.
Terragrunt used to treat a cached source as complete once it had been downloaded, so a file deleted from the store afterwards ended the run with a read failure naming a path inside the store. Recovering meant clearing the store by hand.
A source that no longer supplies the missing content still fails, and now says which object the store is missing. The same is true of a cas:: reference in a stack file, which names stored content directly and has no source behind it to download again, and of a run under --cas-offline, which forbids the download that would restore the store.
catalog sanitizes the content it draws from a repositoryterragrunt catalog browses repositories you point it at, and draws their titles, descriptions, tags and READMEs to the terminal as it finds them. The catalog command did not appropriately sanitize content from repositories to ensure that the content rendered correctly in terminals.
catalog now sanitizes everything it draws, the way terragrunt browse already sanitized the files it previews. Control characters become the Unicode replacement character, so that content draws as visible placeholders. --format jsonl and --format md keep the text as the repository wrote it.
terragrunt catalog when a repository cannot be reachedterragrunt catalog now reports the underlying git error when it cannot reach a repository listed in the catalog block. Previously, this could cause a crash part-way through loading. This affected any repository Terragrunt could not clone, e.g. an SSH URL with no usable key, a private repository without credentials, or a remote that timed out.
find --dependencies lists dependencies in a stable orderWhen a unit had more than one dependency, terragrunt find --dependencies --json could report them in a different order on each run, with no change to the configuration.
The order is now fixed. list, dag graph, and browse sorted before rendering already, so their output is unchanged.
With dependency-fetch-output-from-state enabled, direct S3 state reads now correctly chain the backend's assume_role onto the dependency's execution role. Previously, cross-account dependency state reads failed with 403 AccessDenied when the remote_state block configured a separate assume_role for state access.
With the dependency-fetch-output-from-state experiment enabled, network, permissions, and parsing failures from a direct dependency state read could end a run that worked through native output retrieval.
Outside render and render-json, Terragrunt now retries failed direct reads with tofu output or terraform output. If native output retrieval succeeds, the run continues and only the direct-read speedup is lost. Missing state and the two render commands retain their existing mock-output behavior.
This fallback also covers OpenTofu client-side state encryption. Terragrunt recognizes the encrypted envelope and retries output retrieval through the configured binary instead of treating the dependency as having no outputs. If that binary can decrypt the state and native output retrieval succeeds, only the speedup is lost. render and render-json still require --no-dependency-fetch-output-from-state when they must resolve real outputs from encrypted state.
A setting given under both its current name and a deprecated one took the deprecated value whatever the source of each, so TERRAGRUNT_LOG_LEVEL=debug in the environment overrode TG_LOG_LEVEL=info set beside it.
A command-line argument now beats an environment variable under either name, and at the same level the current name beats the deprecated one. A --terragrunt-* argument still overrides a TG_* variable from the environment, so a script mixing the two keeps working.
exec accepts --source, --source-map, and --no-auto-initterragrunt exec rejected --source, --source-map, and --no-auto-init as invalid flags, one message per flag: flag `--source-map` is not a valid flag for `exec` . It reads configuration and downloads source the same way run does, so there was no way to point exec at a local copy of a module, or to stop it from running init. All three flags are now registered on exec.
terragrunt exec --source-map git::ssh://git@github.com/acme/modules.git=/local/modules -- tfmigrate planexec therefore also reads TG_SOURCE, TG_SOURCE_MAP, and TG_NO_AUTO_INIT, along with the deprecated TERRAGRUNT_SOURCE, TERRAGRUNT_SOURCE_MAP, and TERRAGRUNT_AUTO_INIT, which it previously ignored. If you export any of those for run, exec starts honoring them too.
--no-auto-init reaches the unit exec targets only under --in-download-dir, since exec otherwise never runs init for it. It also reaches units named in dependency blocks, with or without that flag, because Terragrunt initializes a dependency when resolving its outputs requires it.
With the dependency-fetch-output-from-state experiment enabled, a GCS backend authenticated through Workload Identity Federation still ran tofu output or terraform output for every dependency, so the experiment made no difference.
It affected any credentials file of type external_account, which is what google-github-actions/auth writes and points GOOGLE_APPLICATION_CREDENTIALS at. Terragrunt read only service_account and authorized_user files directly.
Terragrunt now reads external_account credentials files directly, including the service-account impersonation that google-github-actions/auth configures when you give it a service account. A direct read requires the file's credential_source to be one of:
urlfile with an absolute pathAny other credential_source keeps the previous behavior, and the dependency still runs tofu output or terraform output. Reading those directly would use Terragrunt's own process rather than the unit's environment to resolve the identity:
executable would run the command with Terragrunt's environment.environment_id such as aws1) would use Terragrunt's AWS credentials.file with a relative path would resolve against Terragrunt's working directory.The impersonate_service_account backend setting is a separate feature and is not affected. Backends that set it still run tofu output or terraform output.
generate blocks are created as 0600A generate block writes files for Terragrunt and the processes it spawns, all of which run as the user who ran Terragrunt. Creating them as 0644 granted read access that nothing uses.
They are now created as 0600. Under the mutable-generate experiment, a block without mutable = true gets a read-only link to a copy shared between working directories, and Terragrunt stores new content as 0400 rather than 0444. With mutable = true, the block keeps a writable 0600 file of its own.
Content the CAS is already holding keeps the permissions it was stored with, since changing them would change every file linked to that copy. Those files stay 0444 until the cache is cleared. Run with --log-level debug to see which ones.
Since v1.1.4, Terragrunt running in a container on an EC2 instance could fail to use the instance's IAM role when the instance metadata service has a hop limit of 1. Runs failed with:
error assuming role: operation error STS: AssumeRole, get identity: get credentials:
failed to refresh cached credentials, no EC2 IMDS role found,
operation error ec2imds: GetMetadata, canceled, context deadline exceeded
In that setup the IMDSv2 token request never gets an answer. Terragrunt v1.1.4 waited on it until the whole credential lookup timed out, so the IMDSv1 fallback that v1.1.3 and earlier relied on never ran.
Terragrunt now gives up on the IMDSv2 token request quickly and falls back to IMDSv1, as it did before v1.1.4. No configuration change is needed.
--json-out-dir plan exportAfter v1.1.4, run --all plan with an IAM role and --json-out-dir could make a second sts:AssumeRole request. That request used the role session itself and failed with AccessDenied unless the role trusted itself.
Terragrunt now caches the assumed session in-process until five minutes before it expires (default session length is one hour when --iam-assume-role-duration is unset), keyed by role configuration and source identity, so the JSON export reuses the first assumption. Setting --iam-assume-role-duration was already a working workaround and remains supported.
If a session cannot be refreshed but has not yet expired, Terragrunt logs a warning and continues with the cached credentials rather than failing the run.
In v1.1.4, the Provider Cache Server started reading OpenTofu's CLI config file locations (~/.tofurc and $XDG_CONFIG_HOME/opentofu/tofurc) regardless of which binary Terragrunt was running. A machine with a stray ~/.tofurc (for example, one declaring a network_mirror) could break terragrunt init for Terraform users with errors like:
ERROR Failed to get provider versions from "network_mirror '...'": invalid character '<' looking for beginning of value
Terragrunt now detects whether the configured binary is OpenTofu or Terraform before starting the cache server and reads only that implementation's CLI config files:
~/.tofurc, ~/.terraformrc, $XDG_CONFIG_HOME/opentofu/tofurc (on Windows: %APPDATA%\tofu.rc, then %APPDATA%\terraform.rc).~/.terraformrc (%APPDATA%\terraform.rc on Windows).*.tfrc and *.tfrc.json fragments from the CLI config directory: ~/.terraform.d (%APPDATA%\terraform.d on Windows), or $XDG_CONFIG_HOME/opentofu for OpenTofu when ~/.terraform.d does not exist.TF_CLI_CONFIG_FILE continues to override the config file location for both implementations.The same selection applies to the credentials read for module registry downloads and version-constraint resolution, kept separately per implementation within a single run.
The implementation is detected from the binary Terragrunt is configured to run at startup (--tf-path, TG_TF_PATH, or the first of tofu/terraform found on PATH); a terraform_binary setting inside a unit's configuration does not change which files the cache server reads. When a run's implementation differs from the one the cache server was configured for, and the two implementations would read different CLI config files on that machine, that run skips the provider cache and uses its own CLI configuration, and Terragrunt prints a warning when such a run initializes providers (init or providers lock). Both implementations resolve to the same files when none of the implementation-specific files above exist, or when TF_CLI_CONFIG_FILE names the file. Every run then uses the cache whichever binary it runs. If detection fails, Terragrunt falls back to OpenTofu's file locations.
Units that failed during config evaluation or dependency output resolution were reported as a run error with an empty cause. The report now records the underlying error text in Cause.
Thanks to @Tensho for contributing this fix!
Downloading unit sources from S3-compatible services (s3::https://minio.example.com/...) now works when credentials are supplied via environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) or IAM roles rather than embedded in the URL query string. Previously, the custom endpoint was only pinned when credentials were present in the URL, causing the AWS SDK to redirect requests to amazonaws.com and fail with InvalidAccessKeyId.
A dependency pointing at a directory that holds a terragrunt.stack.hcl now reads the outputs of units generated by that stack's nested stack blocks. The run queue already waited for those units, but their outputs were missing from the dependency.
Each nested stack adds a level named after it, the same address terragrunt stack output gives those units:
dependency "network" {
config_path = "../network"
}
inputs = {
vpc_id = dependency.network.outputs.vpc.vpc_id
subnet_id = dependency.network.outputs.subnets.subnet.subnet_id
}mock_outputs entries for a nested stack's units nest under the stack's name the same way.
A unit and a nested stack with the same name in one stack file share an address, so a dependency on that stack now errors once both have outputs to read. terragrunt stack output already rejects the same configuration. Rename one of the two blocks to give each its own address.
base64gzip-compat experiment adds a base64gzip_compat HCL functionEnable the new base64gzip-compat experiment to use the base64gzip_compat(str) HCL function.
base64gzip_compat returns the value base64gzip returned in Terragrunt v1.1.3 and earlier, and keeps returning it after Terragrunt 1.2 switches base64gzip() to the current Go encoder. Use it where the encoded value must stay stable across upgrades:
inputs = {
user_data_base64 = base64gzip_compat(file("${get_terragrunt_dir()}/user-data.sh"))
}Calling base64gzip_compat without enabling the base64gzip-compat experiment returns an error. The name may still change to match OpenTofu.
offline-cas gates the CAS probe cacheThe offline-cas experiment has been added as the gate for the probe cache the CAS keeps, in which it records what each source resolved to so a later run can skip asking the remote.
Enabling the experiment turns the cache on and unlocks three flags that change how its answers are used: --cas-offline, --cas-refresh, and --cas-probe-ttl. Setting one of them without the experiment returns an error naming the flag.
terragrunt run --experiment offline-cas --all --cas-offline -- planWithout the experiment nothing is recorded or served, and every run probes every source, as before.
See the experiment documentation for what each flag does and what has to land before it stabilizes.
tg-login reserved for signing in to the Gruntwork Developer PortalThe tg-login experiment has been added as the gate for terragrunt login, a command for signing in to the Gruntwork Developer Portal. Once it lands, signing in lets terragrunt catalog read the repositories your organization selected in the portal rather than a catalog block you maintain yourself.
In this release the flag is reserved only. Enabling it has no effect, and no command reads it.
See the experiment documentation for what is planned and what has to land before it stabilizes.
azure-backend can assign the blob data role during bootstrapCreating an Azure storage account grants no access to the blobs inside it, so an identity using use_azuread_auth could bootstrap the backend and then fail to read state as unauthorized until someone granted the data-plane role by hand.
With the azure-backend experiment enabled, assign_blob_data_role = true now has bootstrap grant Storage Blob Data Contributor on the storage account:
remote_state {
backend = "azurerm"
config = {
storage_account_name = "myterragruntstate"
container_name = "tfstate"
key = "${path_relative_to_include()}/terraform.tfstate"
resource_group_name = "terraform-rg"
use_azuread_auth = true
assign_blob_data_role = true
}
}The role goes to the identity Terragrunt authenticated as, resolved from the access token it already holds rather than from a directory lookup, so it works for identities that cannot read Microsoft Entra. Set principal_id to grant the role to a different user, group, or service principal.
Existing assignments are detected and left alone, so reruns need only read permission on role assignments.
The setting is opt-in: creating a role assignment requires Microsoft.Authorization/roleAssignments/write, which Contributor does not include. Leaving it unset preserves the previous behavior of assigning nothing.
expansion blocks now iterate dependency, unit, and stack blocksWith the block-iteration experiment enabled, a dependency, unit, or stack block can have an expansion block declaring a count or a for_each. Terragrunt reads the block once per element, producing one dependency, unit, or stack for each:
# terragrunt.stack.hcl
unit "aurora" {
expansion {
for_each = toset(["web", "api"])
}
source = "../units/app"
path = "aurora/${each.key}"
values = {
role = each.key
}
}You address each element by its key. An expanded dependency is read as dependency.aurora["web"].outputs.id, and terragrunt stack output 'aurora["web"].role' reaches one element of an expanded unit.
Adding an expansion to a block that did not have one therefore changes its address, and shrinking a for_each or lowering a count removes addresses. Terragrunt has no moved equivalent, so nothing records the rename for you: references and stack output scripts need updating by hand, and state left behind at an address that no longer exists has to be destroyed deliberately.
The experiment also enables an enabled attribute on unit and stack blocks. Setting it to false drops the component from stack generation and from terragrunt stack output, and leaves every other address alone. dependency blocks accept enabled without the experiment.
See the expansion block reference for the rules, the addressing scheme, and how to clean up state left behind when an expansion shrinks.
symlinks experiment: include_in_copy copies the contents of symlinked directories againIn v1.1.4, files behind a symlinked directory named in include_in_copy were not copied into the OpenTofu/Terraform working directory, so they were missing from .terragrunt-cache. exclude_from_copy patterns reaching through a symlinked directory also excluded nothing.
With the symlinks experiment enabled (--experiment symlinks or TG_EXPERIMENT=symlinks), patterns rooted at a symlinked directory expand through the link again, for both include_in_copy and exclude_from_copy, as in v1.1.3 and earlier. Without the experiment, the v1.1.4 behavior is unchanged.
A link that points back at a directory already being copied, or at a parent of one, such as a link to the unit directory itself, is skipped. Terragrunt logs a warning naming the link when that happens.
render previews an expanded dependency block written in JSONWith the block-iteration experiment enabled, a configuration written in JSON now renders the same way an HCL one does. It has no HCL to quote, so Terragrunt writes the block as the HCL that means the same thing and previews the elements underneath it:
$ cat terragrunt.hcl.json
{"dependency": {"shard": {
"expansion": {"count": 2},
"config_path": "../shard-${count.index}"
}}}
$ terragrunt render --experiment block-iteration
dependency "shard" {
expansion {
count = 2
}
config_path = "../shard-${count.index}"
}
# Expands to:
#
# dependency "shard" {
# config_path = "../shard-0"
# }
#
# dependency "shard" {
# config_path = "../shard-1"
# }Previously the elements rendered as ordinary blocks, which repeated one label. Terragrunt warns about that and rejects it under the duplicate-dependency-labels strict control, so the rendered file did not read back.
--format json no longer drops the elements either. Its dependency map is keyed by label, which every element shares, so it kept whichever element came last. JSON has no comment to preview the elements in, so it now emits the block as it was written, references and all:
$ terragrunt render --format json --experiment block-iteration
{
"dependency": {
"shard": {
"expansion": { "count": 2 },
"config_path": "../shard-${count.index}",
"skip_outputs": true
}
}
}Whichever syntax you write and whichever format you ask for, rendering the output again returns it unchanged.
With the block-iteration experiment enabled, a dependency pointing at a directory that holds a terragrunt.stack.hcl collected the outputs of an expanded unit under the block's bare label. Every element wrote to that one label, so only the last one survived, and reading it returned another element's outputs.
Each element is now reachable under its own key, matching the address terragrunt stack output already gives it:
dependency "networking" {
config_path = "../live"
}
inputs = {
web_id = dependency.networking.outputs.aurora["web"].id
api_id = dependency.networking.outputs.aurora["api"].id
}A unit that declares no expansion is still read as dependency.networking.outputs.vpc.id.
tg-login experiment by @yhakbar in #6759render --json expansion logic by @yhakbar in #6763generate script by @yhakbar in #6780git cat-file --batch instead of multiple git cat-file calls per blob by @yhakbar in #6838git rev-parse --show-toplevel as a Go func by @yhakbar in #6867sync.Pool for hot buffers by @yhakbar in #6769dag graph by @yhakbar in #6788block-iteration documentation by @yhakbar in #6844sign-commits to the use of peter-evans/create-pull-request for docs clean-up PRs by @yhakbar in #6891go fix ./... on all tags by @yhakbar in #6766TestUnitPathsFromStackDir_DepthCapReturnsError test by @yhakbar in #6781find --dependencies --json by @yhakbar in #6800for_each in the expansion engine tests by @yhakbar in #6829nolintlint by @yhakbar in Note truncated.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →