github gruntwork-io/terragrunt v1.1.5

5 hours ago

✨ New Features

duplicate-dependency-labels also catches a shared config_path

Two 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

🏎️ Performance Improvements

Fewer remote probes for sources shared across units

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:

  • A source pinned to a specific revision keeps its answer for 24 hours: a semantic version tag, an S3 object version, an OCI manifest digest, an exact registry module version, or a full Mercurial changeset node.
  • A source that can change upstream, such as a Git branch or an OCI tag, gets a fresh probe on every run, so a push or an upload shows up immediately.

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.

Faster first-time source downloads

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.

CAS store improvements

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.

Faster dependents filters

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.

Faster file work, especially on small CI runners

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.

Faster worktrees for Git filters

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.

Lower memory use during run --all

When 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 copied

A 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.

Terragrunt no longer records what units read unless something needs it

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.

Finding the repository root no longer launches git

get_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.

🐛 Bug Fixes

More generated files are written atomically

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:

backend commands no longer fail on unapplied dependencies

backend 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 again

Terragrunt 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-base64gzip

Deleted files in the CAS are fetched again instead of failing the run

When 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 repository

terragrunt 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.

Fixed a crash in terragrunt catalog when a repository cannot be reached

terragrunt 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 order

When 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.

Support backend assume_role during direct dependency state reads

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.

Dependency state read failures fall back

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.

Current flag names take precedence over deprecated ones

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-init

terragrunt 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 plan

exec 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.

Direct GCS state reads work with Workload Identity Federation

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:

  • url
  • file with an absolute path

Any 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.
  • AWS (an 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.

Files from generate blocks are created as 0600

A 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.

EC2 instance role credentials work again from inside a container

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.

IAM role credentials are reused for --json-out-dir plan export

After 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.

Provider Cache Server reads only the running implementation's CLI config files

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:

  • OpenTofu reads the first of these that exists: ~/.tofurc, ~/.terraformrc, $XDG_CONFIG_HOME/opentofu/tofurc (on Windows: %APPDATA%\tofu.rc, then %APPDATA%\terraform.rc).
  • Terraform reads only ~/.terraformrc (%APPDATA%\terraform.rc on Windows).
  • For both implementations, Terragrunt also merges the *.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.
  • Setting 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.

Run report includes cause for units that fail before OpenTofu/Terraform

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!

Fixed S3-compatible source downloads with environment credentials

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.

Stack dependencies include outputs from nested stacks

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.

🧪 Experiments Added

base64gzip-compat experiment adds a base64gzip_compat HCL function

Enable 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 cache

The 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 -- plan

Without 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 Portal

The 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.

🧪 Experiments Updated

azure-backend can assign the blob data role during bootstrap

Creating 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 blocks

With 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 again

In 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 JSON

With 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.

Expanded units keep their own outputs when a whole stack is a dependency

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.

Pull Requests

✨ Features

🐛 Bug Fixes

  • fix(cliconfig): isolate cloned helpers and hosts by @denis256 in #6782
  • fix: Check duplicate dependency config path by @yhakbar in #6834
  • fix: Writing more generated files atomically by @yhakbar in #6776
  • fix(config): harden dependency state output fetching by @denis256 in #6794
  • fix(provider-cache): Fix provider cache configuration selection by @denis256 in #6789
  • fix: Sanitizing some untrusted input by @yhakbar in #6802
  • fix: restore AWS IMDS credential fallback for container environments by @denis256 in #6837
  • fix(gcs): read dependency outputs from state with external_account creds by @denis256 in #6815
  • fix: Expand include_in_copy/exclude_from_copy globs through symlinked directories by @denis256 in #6817
  • fix: Fixing stack dependency unit output cty access by @yhakbar in #6839
  • fix: adding --source-map and --no-auto-init to terragrunt exec by @denis256 in #6792
  • fix(config): restore v1.1.3 base64gzip output by default by @denis256 in #6835
  • fix(getter): pin S3-compatible endpoint independently of URL credentials by @denis256 in #6857
  • fix: Fixing catalog nil logger panic by @yhakbar in #6871
  • fix: Fixing render --json expansion logic by @yhakbar in #6763
  • fix: populate run report cause for units that fail before tofu/terraform runs by @Tensho in #6819
  • fix: Use partial parse when possible for backend commands by @yhakbar in #6790
  • fix(config): use backend assume_role for direct dependency state reads by @denis256 in #6883
  • fix: Preventing Windows flag usage panic in tests by @yhakbar in #6877
  • fix: Adding store repair for corrupted CAS stores by @yhakbar in #6872
  • fix: Fixing accessing nested stack outputs in stack dependencies by @yhakbar in #6892
  • fix: Fixing mutable-generate tmp file leak by @yhakbar in #6895
  • fix(amazonsts): stop json-out-dir IAM self-assume by @denis256 in #6899

🏎️ Performance

  • perf: Pre-compile color table with a generate script by @yhakbar in #6780
  • perf: Use git cat-file --batch instead of multiple git cat-file calls per blob by @yhakbar in #6838
  • perf: Tuning FS concurrency by @yhakbar in #6854
  • perf: Inline git rev-parse --show-toplevel as a Go func by @yhakbar in #6867
  • perf: Streaming buffered output by @yhakbar in #6761
  • perf: Using singleflight cached CAS probe by @yhakbar in #6841
  • perf: Using a sync.Pool for hot buffers by @yhakbar in #6769
  • perf: Reuse already-parsed configs when discovering dependents by @yhakbar in #6849
  • perf: Improving worktree performance by @yhakbar in #6864
  • perf: Avoid tracking reading when unnecessary by @yhakbar in #6862
  • perf: Improve CAS store hygiene by @yhakbar in #6843

📖 Documentation

🧹 Chores

  • chore: Adding exec sandboxed logic to seatbelt by @yhakbar in #6760
  • chore: Collapse runner packages by @yhakbar in #6765
  • chore: Running go fix ./... on all tags by @yhakbar in #6766
  • chore: dependencies update by @denis256 in #6719
  • chore: Validating device auth duration better by @yhakbar in #6779
  • chore: Moving integration tests to in-memory CLI tests by @yhakbar in #6771
  • chore: Moving discovery tests in-memory by @yhakbar in #6774
  • chore: Typed stack validation errors and consistent generated-path comparison by @yhakbar in #6777
  • chore: Adding injectable cap to speed up TestUnitPathsFromStackDir_DepthCapReturnsError test by @yhakbar in #6781
  • chore: Modernizing with new Go 1.27 constructs by @yhakbar in #6772
  • chore: Fixing lints on main by @yhakbar in #6785
  • chore: go mod fixes by @denis256 in #6786
  • chore: Deterministic dependency order for find --dependencies --json by @yhakbar in #6800
  • chore(deps): bump google.golang.org/grpc from 1.83.0 to 1.83.1 by @dependabot[bot] in #6808
  • chore(deps): bump the js-dependencies group across 1 directory with 11 updates by @dependabot[bot] in #6806
  • chore(deps): bump go dependencies by @denis256 in #6820
  • chore: Adding test coverage for filters with block iteration by @yhakbar in #6830
  • chore: Improving expansion diagnostics by @yhakbar in #6833
  • chore: Cover object and tuple for_each in the expansion engine tests by @yhakbar in #6829
  • chore: Enabling nolintlint by @yhakbar in #6803
  • chore: Adding explicit capacity hints by @yhakbar in #6825
  • chore: Adding full block-iteration lifecycle tests by @yhakbar in #6846
  • chore: Addressing review feedback from #6854 by @yhakbar in #6865
  • chore: Fixing cloud credentials test env semantics by @yhakbar in #6826
  • chore: Fixing sandbox tests by @yhakbar in #6882
  • chore(deps): bump astro from 7.2.7 to 7.2.8 in /docs by @dependabot[bot] in #6855
  • chore(deps): bump the js-dependencies group across 1 directory with 8 updates by @dependabot[bot] in #6885
  • chore: Clean-up partial worktrees on failure by @yhakbar in #6884
  • chore: Bumping go-runewidth to v0.0.30 by @yhakbar in #6879
  • chore: Bumping go-getter to v2.2.4 by @yhakbar in #6881
  • chore: Generating .terraform directory on-demand instead of leaving it committed in the repo by @yhakbar in #6887
  • chore: Globally replacing xsync.Map with a map and mutex by @yhakbar in #6880
  • chore: General clean-up from recent PRs by @yhakbar in #6886
  • chore: Revert to materializing worktrees with git checkout instead of git archive by @yhakbar in #6890

Don't miss a new terragrunt release

NewReleases is sending notifications on new releases.