
Delivery
Terragrunt vs Terraform: What Native HCL Now Covers Without It
Terraform 1.15 accepts variables in module sources; its backend block still refuses them. What that leaves Terragrunt doing, and what leaving it costs.
Most comparisons of the two read like a feature checklist written before April 2026. They miss that the ground moved under the question three times this year: Terraform 1.15 started accepting variables in module sources, Terragrunt 1.0 shipped a compatibility guarantee, and Terragrunt 1.2 changed how it reads outputs from one unit into another.
So the useful question is no longer "which is better". It is: which of the three gaps does your setup still have, on the binary you actually run, and what does the wrapper cost you in failure modes and in exit effort?
Terragrunt is a wrapper, so the question is which gap it still fills
Terragrunt does not plan or apply anything itself. It reads terragrunt.hcl files, generates files into each working directory, and shells out to an IaC binary. Which binary is a default worth knowing: per the run command reference, the default is tofu on PATH; if only terraform is installed, Terragrunt uses that; and --tf-path (or TG_TF_PATH) overrides the choice "in all instances, including dependency lookups".
Its vocabulary is small. In the terminology guide, a unit is "a single instance of infrastructure managed by Terragrunt" that "has its own state" and is detected by the presence of a terragrunt.hcl file in a directory; a stack is "a collection of units". A unit is, in effect, one Terraform root module plus the configuration that wraps it.
The versions this article is written against, all current on 31 October 2026: Terraform v1.17.1, OpenTofu v1.13.2 and Terragrunt v1.2.1, released on 23 October, two days after v1.2.0. The diagram below is the whole argument.
Gap one: the backend block that cannot take a variable
Terragrunt's own state backend documentation states the reason it was built: the backend configuration "does not currently support expressions, variables, or functions", so with several root modules "you would have to copy/paste the identical backend configuration into each of the main.tf files", differing only in key. Terraform's backend page still says the same thing for the current release: "A backend block cannot refer to named values (like input variables, locals, or data source attributes)."
Terraform: partial configuration
Terraform's answer is partial configuration: omit some or all backend arguments and supply them at init, either as a file with -backend-config=PATH or as pairs with -backend-config="KEY=VALUE". It works, and it moves the duplication from HCL into your pipeline scripts.
#!/usr/bin/env bash
set -euo pipefail
# main.tf declares an empty block: terraform { backend "s3" {} }
# envs/prod.s3.tfbackend holds the per-environment arguments, e.g.
# bucket = "example-tf-state-prod"
# key = "network/terraform.tfstate"
# region = "eu-central-1"
# use_lockfile = true
STATE_KEY="${1:-}"
if [[ -z "$STATE_KEY" ]]; then
terraform init -backend-config=envs/prod.s3.tfbackend
else
# One argument overriding the file, e.g. a canary state key.
terraform init -reconfigure \
-backend-config=envs/prod.s3.tfbackend \
-backend-config="key=$STATE_KEY"
fiOne property matters for audit: the docs warn that if you use -backend-config or hard-code these values, "Terraform will include these values in both the .terraform subdirectory and in plan files". Keep credentials out of backend arguments entirely and let the backend pick them up from the environment.
OpenTofu: early evaluation
OpenTofu closed this gap first. The 1.8.0 release post introduced early evaluation: "You can now use variables and locals in places that were not previously available, such as module sources, backend configuration and state encryption." The restriction is real, and the backend configuration page spells it out: backend configuration "may not contain any references to data in the state or provider defined functions", and "all values must be able to be resolved during tofu init before the state is available".
variable "environment" {
type = string
}
locals {
state_key = "${var.environment}/network/tofu.tfstate"
}
terraform {
backend "s3" {
bucket = "example-tofu-state"
key = local.state_key
region = "eu-central-1"
encrypt = true
use_lockfile = true
}
}Terragrunt: generate the backend per unit
Terragrunt writes the backend block for you. A remote_state block with generate in one root file produces a backend.tf inside each unit, and path_relative_to_include() derives the key from the unit's directory. The example below follows the docs' S3 shape with native S3 locking (use_lockfile), which the blocks reference notes requires OpenTofu 1.10 or later.
# live/root.hcl
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "example-tofu-state"
key = "${path_relative_to_include()}/tofu.tfstate"
region = "eu-central-1"
encrypt = true
use_lockfile = true
}
}Terragrunt can also create the bucket, but not silently: it does so only on terragrunt backend bootstrap or with --backend-bootstrap, and the docs note that flag "defaults to false". Automatic creation is supported for s3, gcs and azurerm backends only.
Gap two: module sources and versions per environment
The second gap was promotion: staging on module v1.5.0, production on v1.4.2, without two copies of the calling code. Terraform's v1.15 changelog closes it for the core binary — 1.15.0, dated April 29, 2026: "Terraform now supports variables and locals in module source and version attributes."
The mechanism is a new variable argument. The variable block reference describes const as allowing the variable "to be used during early Terraform operations, such as initialization"; a const variable "can only contain a known, constant value". The module configuration page makes it mandatory: any input variable referenced in source or version "must declare const = true", and those expressions "can only reference things that are known during configuration loading".
variable "network_module_repo" {
type = string
const = true
}
variable "network_module_ref" {
type = string
const = true
}
variable "consul_version" {
type = string
const = true
}
locals {
network_source = "git::https://git.example.com/${var.network_module_repo}.git?ref=${var.network_module_ref}"
}
module "network" {
source = local.network_source
}
module "consul" {
source = "hashicorp/consul/aws"
version = var.consul_version
}The constraint to plan around is timing. These values have to exist when Terraform loads configuration, so they cannot come from a data source, a resource attribute or anything computed during plan. Whatever your pipeline uses to feed them per environment must deliver them by init. OpenTofu has had the equivalent since 1.8 through early evaluation, as the release post above records.
Terragrunt's answer has always been a terraform { source = ... } block per unit, pinned to a Git ref, with environment differences expressed as directories. It still works, and for module pinning alone it is no longer a reason to adopt the wrapper on either binary. (Terraform 1.17.0, released October 21, 2026, also added variables and locals in provider requirements — adjacent, but not one of the three gaps.)
Gap three: ordering and wiring many root modules
This is the gap that keeps Terragrunt relevant. Split infrastructure into many small root modules — network, cluster, database, applications — and two problems appear: one root needs another's outputs (wiring), and the roots must apply in dependency order (ordering). Plain Terraform and OpenTofu solve the first and not the second.
Wiring natively, and HashiCorp's own caution
The native wiring is the terraform_remote_state data source, and HashiCorp is cautious about it. The remote state data page recommends "explicitly publishing data for external consumption to a separate location instead of accessing it via remote state", because although the data source exposes only outputs, "its user must have access to the entire state snapshot, which often includes some sensitive information" — and says not to use it at all if any resources in your configuration handle sensitive data. Ordering is left to whoever writes the pipeline.
Terragrunt: dependency blocks and a DAG
Terragrunt does both. Per the blocks reference, each dependency block "exports the outputs of the target module as block attributes", and during a run --all operation "Terragrunt first parses all the dependency blocks to build a dependency tree" and runs units in that order. mock_outputs exist for plans against units that are not yet applied, and mean "use mocks only if real outputs are not available".
# live/prod/app/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::https://git.example.com/infra-modules.git//app?ref=v2.3.0"
}
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-00000000"
private_subnet_ids = ["subnet-00000000"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.private_subnet_ids
}Restricting mock_outputs_allowed_terraform_commands is not optional hygiene. The reference says that if a command is not in that list and the target has no outputs, "Terragrunt will throw an error when processing this dependency" — which is the behaviour you want for apply.
Since v1.2.0, how those outputs are fetched has changed. The run reference says Terragrunt "will read the outputs directly from the state file instead of running tofu output or terraform output", that this is "the default since v1.2.0", and that it supports S3, GCS and azurerm backends only; "other backends continue to use tofu output or terraform output". Whoever runs a dependent unit therefore needs read access to the dependency's state object — the same access question HashiCorp raises about terraform_remote_state.
Terraform Stacks: native ordering, hosted
HashiCorp's native answer to the same gap is Stacks. The Stacks documentation calls them "a powerful configuration layer in HCP Terraform", configured in .tfcomponent.hcl and .tfdeploy.hcl files, where "each deployment has an isolated state file" and you "use HCP Terraform to deploy your Stack's infrastructure". Stacks are not available on legacy HCP Terraform team plans, and the GA update moved the commands into the main CLI as terraform stacks, with Stacks resources-under-management "aggregated with your workspace RUM for billing purposes". It is a real alternative — on HashiCorp's control plane and HashiCorp's meter. HCP Terraform also offers run triggers "allow runs to queue automatically in your workspace on successful apply of runs in any of the source workspaces", with up to 20 source workspaces per workspace. That orders workspaces, but yields no single ordered plan.
The state layout Terragrunt imposes
With key = "${path_relative_to_include()}/tofu.tfstate", the directory tree is the state map. Every unit gets its own object in the bucket; a plan for prod/app cannot touch the resources recorded under prod/vpc. That is a blast-radius property worth having, and it is stronger than the common alternative. HashiCorp's workspaces page says CLI workspaces "are not a suitable tool for system decomposition", and because workspaces in one working directory use the same backend, "they are not a suitable isolation mechanism" where deployments need separate credentials and access controls.
The cost is the mirror image. Because the key is derived from the path, renaming or moving a unit directory changes the key that the generated backend.tf points at. The old object stays where it was, and the unit in its new location points at a key with no state behind it — so a plan would propose creating everything again. Treat a directory move as a state migration: move the state object to the new key first, then move the directory, then confirm a plan shows no changes.
This is the same discipline argued in Terraform vs Ansible: the state file is the record you own, and its address is part of the record.
Failure modes Terragrunt adds
A wrapper that runs many root modules at once can do more damage at once. The run reference documents each of these; none is a bug, and all of them surprise people.
- Plans against mocks. A
run --all planon a fresh environment shows values computed frommock_outputs, not reality. Name mocks so they are unmistakable. - Plan fails on undeployed dependencies. The docs warn that
run --all plan"will fail if those dependencies have never been deployed", because Terragrunt "cannot resolve dependency outputs without existing state" — unless mocks cover that command. - Implicit auto-approve. With
run --allandapplyordestroy, Terragrunt "automatically adds the-auto-approveflag". There is no per-unit confirmation; the gate has to be the reviewed plan upstream.--no-auto-approve(TG_NO_AUTO_APPROVE) turns that off. - Destroy reaches every unit below you. From a parent directory,
run --all destroydestroys every unit under it, dependencies included. Outside it, the queue-to-filter guide is explicit for 1.2: "External dependencies are now excluded by default."--queue-exclude-externalis deprecated; the old cascade is opt-in via--queue-include-externalor--filter '{./**}...';--queue-exclude-dir(a negated--filter) carves directories out. The run page still carries the older cascade warning — trust the binary you pin. - Ignoring the DAG.
--queue-ignore-dag-orderruns units in arbitrary order; the docs suggest it for read-only operations likeplanand warn it can lead to errors with commands that modify state. - The wrong binary. With both binaries installed, Terragrunt picks
tofu. The two already differ on what a backend block accepts.
#!/usr/bin/env bash
set -euo pipefail
cd live/prod
# Pin the binary explicitly; it also applies to dependency lookups.
export TG_TF_PATH=terraform
# Plan every unit in dependency order.
terragrunt run --all plan
# Apply: Terragrunt adds -auto-approve to every unit here.
terragrunt run --all apply
# Destroy every unit under this directory. External dependencies are
# excluded by default in 1.2; --queue-exclude-dir carves out more.
terragrunt run --all --queue-exclude-dir=./vpc -- destroy
# Generate units from terragrunt.stack.hcl into .terragrunt-stack/
terragrunt stack generateTwo migrations are in flight. The CLI redesign deprecated run-all in favour of run --all. And Terragrunt now recommends renaming the root terragrunt.hcl to something like root.hcl: the old pattern emits warnings, will become an error in a future version, and support will not be removed before Terragrunt 2.0. The snippets here already use root.hcl.
Terragrunt's own stacks are a generator, not a second orchestrator. Per the explicit stacks page, a terragrunt.stack.hcl file "is a blueprint" that generates units into a .terragrunt-stack directory, and it "has no dependency block of its own" — ordering still comes from the units' dependency blocks.
# live/prod/terragrunt.stack.hcl
unit "vpc" {
source = "git::https://git.example.com/infra-catalog.git//units/vpc?ref=v1.4.0"
path = "vpc"
values = {
cidr = "10.20.0.0/16"
}
}
unit "app" {
source = "git::https://git.example.com/infra-catalog.git//units/app?ref=v1.4.0"
path = "app"
values = {
vpc_path = "../vpc"
}
}What an auditor sees, and what leaving costs
For an auditor the evidence trail is good, if you commit the right things. The generated backend.tf in each unit names exactly where that unit's state lives; per-unit state keys map one-to-one to directories; and every source carries a Git ref, so the module version in production is a line in a reviewed file. Add the bootstrap default — no backend resources created unless someone asks — and the change surface is narrow and legible.
The 1.0 guarantees lower the cost of staying. The cost of leaving depends on how much of the wrapper you use. The licence history behind that choice is covered in the OpenTofu migration article; the mechanics of an exit back to plain root modules are a short sequence:
- Pin the binary and freeze. Set
--tf-pathexplicitly, runrun --all planand require zero changes everywhere before touching anything. - Materialise each unit. Vendor the files
terraform.sourcepoints at into the unit directory as the root module, beside the generatedbackend.tf. Terragrunt emits nomoduleblock; wrapping the source in one moves every resource address, and the next plan proposes recreating the estate. - Replace inputs. Turn each unit's
inputsinto a.tfvarsfile, and each dependency into a published output — a parameter store or a small data file, per HashiCorp's advice — or aterraform_remote_statedata source where the data is not sensitive. - Keep the state keys. The generated keys already exist; reuse them verbatim so no state moves during the exit.
- Rebuild the ordering. Encode the dependency order your DAG used to provide as explicit pipeline stages, then verify each root plans clean with plain
terraformortofu.
Step five is the real cost, and it is proportional to how many roots you have. That is the honest test of the wrapper: it is cheap to leave when the DAG is shallow and expensive when it is the thing holding the estate together. The same exit-ramp reasoning applies to the forge your CI runs on, covered in Git, GitHub, GitLab.
Deciding in 2026: a gap ledger
Read the ledger against your own setup rather than a feature list.
- On OpenTofu with a handful of roots: early evaluation covers backends and module sources natively. Wire with published outputs, order with pipeline stages, and skip the dependency.
- On the Terraform CLI with many roots and no HCP: module sources are native since 1.15, but the backend block still refuses variables and nothing native orders roots. Terragrunt still earns its place here — with the binary pinned and destroy guarded.
- On HCP Terraform: Stacks is the native contender for ordering and per-deployment state. Run triggers also order workspaces, without one plan. Weigh both against the lock-in: a hosted control plane, Stacks' absence from legacy team plans, and RUM billing.
Whichever you pick, keep the boundary drawn in Terraform vs CloudFormation and Terraform vs Kubernetes in view: the state is the asset, and the tool that writes it is replaceable. Wrapper tools tend to outlive the problem they were adopted for; the long game is noticing when a gap has closed and paying down the wrapper before it becomes structural.
§FAQ/Common questions
Frequently asked
Is Terragrunt an alternative to Terraform?
No. Terragrunt is an MIT-licensed wrapper that runs tofu or terraform underneath. It defaults to tofu when available and falls back to terraform if only that is on PATH; --tf-path overrides it, including for dependency lookups. It adds backend generation, per-unit module sourcing and dependency-ordered runs across many root modules.
Can a Terraform backend block use variables?
Not in Terraform: the current docs say a backend block cannot refer to input variables, locals or data source attributes. Use partial configuration with -backend-config at terraform init, noting those values are stored in .terraform and in plan files. OpenTofu has allowed variables and locals in backend configuration since 1.8, provided everything resolves during tofu init without state or provider-defined functions.
Can Terraform modules use a variable for source or version?
Yes, since Terraform 1.15.0 (April 29, 2026). Any input variable referenced in a module's source or version must declare const = true, and the expressions may only reference values known during configuration loading — not data sources or computed attributes.
Why does terragrunt run --all plan fail on a new environment?
Terragrunt cannot resolve dependency outputs for units that have never been deployed, so run --all plan fails unless the dependency block defines mock_outputs and allows them for plan via mock_outputs_allowed_terraform_commands. Keep apply out of that list so real outputs are required.
Are Terraform Stacks a replacement for Terragrunt?
For ordering and per-deployment state, they are HashiCorp's native answer, but they are documented as a configuration layer in HCP Terraform, deployed through HCP Terraform and billed with workspace resources under management. They are not available on legacy HCP Terraform team plans.
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.