Skip to main content

OpenTofu and Terraform

The BuiltForProd platform is built on OpenTofu and Terragrunt. OpenTofu is the open-source, Terraform-compatible engine that plans and applies the modules; Terragrunt is the layer above it that generates the units, the provider configuration and the state backend. The word "Terraform" still appears in file names and module names because the language and the ecosystem are shared.

Why it matters in production

The engine that changes your infrastructure must be predictable. Every engineer and every pipeline has to run the same version, or a plan on one machine will not match the apply on another and a state file written by a newer version cannot be read by an older one. Open source matters for the same reason: the tool that holds your infrastructure definition should not be able to change its terms under you.

How the platform applies it

One place for every version

root.hcl in each infrastructure repository is the only place OpenTofu and the AWS provider are pinned. It generates a versions.tf into every unit (if_exists = "skip", so a module that ships its own is kept) and a provider.tf with the provider configuration, assume-role logic and default tags (always overwritten).

root.hcl (version pins)
terraform_version_constraint = ">= 1.12.6, < 2.0.0"
terragrunt_version_constraint = ">= 1.1.5"

generate "versions" {
path = "versions.tf"
if_exists = "skip"
contents = <<-EOF
terraform {
required_version = ">= 1.12.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.65"
}
}
}
EOF
}

CI pins exact versions in every workflow: OpenTofu 1.12.6 and Terragrunt 1.1.5 as TF_VERSION and TG_VERSION. A module ships its own versions.tf only when it uses a provider alias or a provider root.hcl does not pin (the web platform's helm ~> 3.3 and kubernetes ~> 3.2), and then repeats the root pins exactly. scripts/check-module-versions.py enforces this in pre-commit and CI. The full pin table is on the release notes page.

Where Terraform still shows

NameWhat it is
terraform { source = ... } blockThe Terragrunt block that names a unit's module; the block name is inherited from the language.
terraform.tfstateThe state file name under every state key.
terraform-aws-modules/*The community registry modules the platform pins, such as vpc 6.7.3 and eks 21.25.1, referenced as tfr:///terraform-aws-modules/<name>/aws?version=<pinned>.
acme-terraform-deployer, acme-terraform-state-accessThe deployer role in every account and the state-access role in the management account.
terraform_version_constraintThe Terragrunt attribute that pins the engine.

The engine itself is OpenTofu: the workflows install it with the OpenTofu setup action, the pre-commit hooks run tofu fmt, tofu validate and tofu docs, and every resource carries the tag ManagedBy = "opentofu".

Features the platform relies on

The DocumentDB module generates its master password with an ephemeral resource and writes it through write-only arguments (master_password_wo, value_wo), so the password never appears in a plan or in state. Ephemeral resources and write-only arguments need OpenTofu 1.11 or later, which is one reason the repositories pin 1.12.

Modules in three tiers

TierWhenExample
Registry directA registry module does the job with inputs onlyunits/vpc-spoke uses terraform-aws-modules/vpc
Thin wrapperA registry module plus lookups or gluemodules/eks, modules/s3, modules/lambda
CustomNo suitable registry module existsmodules/organizations, modules/ipam, modules/transit-gateway-routes

Modules are named by resource type (lambda/, not lambda-trigger/), take injected dependencies (a DocumentDB module takes security group IDs; it does not create a security group), and never contain hand-written provider or backend blocks, because Terragrunt generates them. Linting uses tflint v0.64.0 with the AWS ruleset 0.48.0; the two rules that would flag every module for omitting required_version and required_providers are disabled on purpose, because those live in the generated versions.tf.

Worked example: upgrading the AWS provider

A provider upgrade is one change in root.hcl (the ~> 6.65 pin) plus the same pin in every module versions.tf the guard lists. The plan runs in dev first, then staging, then prod, and the reviewer looks for resource replacements. Rollback is reverting root.hcl. Because units are generated from one definition each, the plan covers every unit in the repository in one run.

Common mistakes

  • Pinning a provider inside a module that does not need to. required_providers belongs in root.hcl; the guard fails a module versions.tf that exists without a provider alias or an extra provider.
  • Running a different OpenTofu version locally. Use the versions in root.hcl and the workflows; a newer engine can write state an older pipeline cannot read.
  • Reading "Terraform" in a file name as a sign the engine is Terraform. The block names, the state file name and the registry namespace are shared; the binary is tofu.
  • Upgrading a registry module and the provider in one change. Each upgrade is its own change with its own plan review.