Terragrunt as a Runtime Manager: Pinning OpenTofu and Terraform Versions
Introduction
In May 2024, several teams found out on Monday that their pipelines had broken over the weekend. The base image they used had switched to calling OpenTofu instead of Terraform, and the version constraints declared in the code stopped being satisfied.
Nobody had changed anything. The change came from outside, through an image tag that pointed at the latest version.
The episode is a good reminder of a question that tends to go unowned: who, exactly, decides which binary and which version run your infrastructure?
In repositories that use Terragrunt, that answer can and should live in the code itself.
Three Questions the Runtime Needs to Answer
Worth organizing the problem before solving it, since it has distinct layers that tend to get treated as one:
- Which binary runs? Terraform or OpenTofu.
- Which version is acceptable? A defined range, not whatever happens to be installed.
- Who installs and pins that version? The engineer’s machine and the CI runner need to agree.
Terragrunt offers a declarative answer to the first two. The third depends on your pipeline, but gains a safety net once the previous two are configured.
Layer 1: Which Binary Runs
Current Terragrunt versions already call tofu by default. Even so, declaring the choice explicitly is good practice, since it removes the dependency on whatever the environment happened to decide for you:
1
2
# root.hcl
terraform_binary = "tofu"
When the decision needs to vary per pipeline, there’s a matching environment variable:
1
export TG_TF_PATH=$(which tofu)
And to find out what’s actually in use, without guessing:
1
terragrunt info print
The corresponding field in the output shows which binary will be called. That command resolves most environment questions in a few seconds.
Notice the conceptual gain here: the choice of execution engine stops being a machine characteristic and becomes part of the versioned configuration.
Layer 2: Which Version Is Acceptable
Knowing which binary runs isn’t enough. A plan generated by one version can differ from one generated by another, and that’s where the mismatches between what the engineer saw locally and what the pipeline applied show up.
Terragrunt lets you declare constraints for both sides:
1
2
3
# root.hcl
terragrunt_version_constraint = ">= 1.0.0, < 2.0.0"
terraform_version_constraint = ">= 1.11.0, < 2.0.0"
When someone runs outside that range, the command fails before any operation, with a message pointing at both the installed and the required version. That’s a cheap failure, upfront, instead of divergent behavior discovered later.
Worth noting that terraform_version_constraint has been around for a while and was originally meant for something else: letting you use Terragrunt with a version not yet officially tested, by relaxing the internal check. The same mechanism works in reverse too, to tighten the accepted range.
Layer 3: The Constraint the Engine Sees
There’s still a third check, done by Terraform or OpenTofu itself through required_version. Since it lives inside the infrastructure code, it tends to end up duplicated across dozens of modules.
The generate block solves that from the root:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# root.hcl
generate "versions" {
path = "versions.tf"
if_exists = "overwrite"
contents = <<EOF
terraform {
required_version = ">= 1.11.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
}
EOF
}
With this, the accepted range gets defined in a single place and propagated to every unit. Changing the version policy becomes a one-line change.
Layer 4: Installing and Pinning in CI
The previous layers validate. This one installs.
The official action lets you pin both versions directly in the workflow:
1
2
3
4
5
- name: Install Terragrunt and OpenTofu
uses: gruntwork-io/terragrunt-action@v3
with:
tg_version: "1.0.2"
tofu_version: "1.11.6"
If you want to choose the binary explicitly, there’s a path parameter:
1
2
with:
tf_path: "terraform"
There’s also the option of declaring the tools in a dedicated version-management file, committed alongside the repository. When it’s present, the action uses it as the source of versions, which makes the engineer’s machine and the runner read the exact same declaration.
That’s the configuration that cuts down environment drift the most, since it removes the duplication between what’s in the workflow and what’s on the workstation.
Gradual Migration Between Engines
There’s a less obvious use for these configurations, and it’s the most valuable one in large repositories.
Since choosing the binary is a Terragrunt setting, it can be overridden in a specific unit, independent of what the root defines. That lets you migrate from Terraform to OpenTofu incrementally, keeping most of the repository on the current engine while one low-risk unit validates the new one.
If validation fails, rolling back is removing one line, and no other unit was affected.
This is the point that tends to go unnoticed: keeping orchestration separate from execution isn’t an aesthetic preference, it’s what makes swapping the engine an incremental, reversible operation.
Things to Watch
Two points deserve attention when setting your constraints.
The two projects’ numbering has drifted apart. Terraform and OpenTofu share an origin, but follow their own release cadences. A range written with one in mind doesn’t necessarily describe the same set of features in the other. Review your constraints when switching engines, instead of assuming equivalence.
The compatibility table has known limits. Terragrunt’s documentation publishes which combinations are officially tested and acknowledges that, in practice, compatibility is broader than the table suggests. Use the table as a support reference, not as the absolute boundary of what works.
Conclusion
Version constraints look like bureaucracy right up until the day a different plan gets applied because of a different binary. After that, they start looking like the bare minimum.
What these configurations do, combined, is turn the runtime into part of the versioned configuration. The question of what’s installed on whoever’s machine ran the command stops existing, because the answer lives in the repository, subject to review like any other architectural decision.
This change has a side effect that might be the most important part: it turns swapping engines into a reversible technical operation, not a project. Once Terragrunt can declare which binary to call and which range to accept, migrating between Terraform and OpenTofu stops being an irreversible infrastructure decision and becomes a configuration line you can test on one unit and undo the next day.