rules_terraform
Overview
This repository implements Bazel rules for Terraform and OpenTofu.
Setup
Add rules_terraform to your MODULE.bazel:
bazel_dep(name = "rules_terraform", version = "{version}")
Toolchains for both engines are auto-registered — no use_extension or
register_toolchains call is required. Pick a specific version by flipping
the corresponding string flag:
--@rules_terraform//terraform/settings:version=1.14.1
--@rules_terraform//opentofu/settings:version=1.10.6
Both default to the latest release shipped in
//terraform/private:versions.bzl /
//opentofu/private:versions.bzl.
To fetch external providers or registry modules, opt into the terraform
extension explicitly:
terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
terraform.providers(
name = "my_providers",
lock = "//path/to:.terraform.lock.hcl",
)
use_repo(terraform, "my_providers")
Running the engine CLI directly (@terraform / @opentofu)
rules_terraform ships two hub repositories — @terraform and @opentofu
— that let a user invoke the toolchain-resolved engine binary from any
subdirectory of their workspace:
cd path/to/my/tf/module
bazel run @terraform -- plan # ≈ terraform plan
bazel run @terraform -- init # ≈ terraform init
bazel run @opentofu -- state list # ≈ tofu state list
The wrapper chdirs to $BUILD_WORKING_DIRECTORY (the shell cwd where
bazel run was invoked) before exec-ing the engine, so relative paths
and any terraform.tfstate land in the directory you're standing in —
just like the native CLI. Version resolution follows the same
//terraform/settings:version / //opentofu/settings:version flags as
the rest of the ruleset, so you get one hermetic Terraform release
across all invocations.
This is NOT the Bazel-managed workflow. @terraform does no init
aspect, no lock-file rewriting, no .terraform construction —
terraform init still runs against your source tree, hits the network,
and writes state. Reach for it when you want a one-off CLI invocation
(state manipulation, ad-hoc plan against an unrelated module) without
wiring up a terraform_binary target for the module.
To use @terraform / @opentofu from downstream, add them to your
MODULE.bazel:
terraform_toolchains = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform_toolchains")
use_repo(terraform_toolchains, "terraform")
opentofu_toolchains = use_extension("@rules_terraform//opentofu:extensions.bzl", "opentofu_toolchains")
use_repo(opentofu_toolchains, "opentofu")
The extensions themselves are already invoked by rules_terraform's own
MODULE.bazel (they register toolchains); the use_repo line only
binds the repo name in your namespace so @terraform / @opentofu
resolve.
Bazel-managed Terraform
When a terraform_module is built through Bazel, the .terraform
directory is assembled hermetically by an init aspect rather than by
terraform init. Every network fetch happens at repository-rule /
bzlmod-extension time; build actions are offline. As a consequence:
- Provider binaries are fetched at repository-rule time and copied
into
.terraform/providers/…during the aspect's action. The lock file'sh1:hashes are rewritten to match the actually-installed binary layout. - External modules (from a Terraform registry) are resolved via a
Bazel-owned lock file, downloaded at repository-rule time, and copied
into
.terraform/modules/…with a generatedmodules.jsonmanifest. - Cross-package modules (other
terraform_moduletargets in the monorepo) are wired via themodule_sourcesattribute on the parent and materialized into.terraform/modules/…alongside the registry modules.
The full mechanics — how the extensions resolve each dependency type, what each lock file looks like, and how to keep them fresh — are in External dependencies and lock files below.
Direct terraform CLI incompatibility
Once a terraform_module has Bazel-managed dependencies, running
terraform (or tofu) directly outside Bazel is not expected to work:
the .terraform directory is Bazel's construction, h1: hashes are
rewritten for the installed platform binary, and modules.json reflects
the Bazel target graph — not the source tree.
Use bazel run to invoke the engine:
# Instead of: terraform plan
bazel run //path/to:terraform -- plan
# Instead of: terraform apply
bazel run //path/to:terraform -- apply
The terraform_binary rule creates an executable that sets up a
hermetic working directory with all dependencies wired into place
before delegating to the real terraform binary.
For validation and formatting, use the corresponding test rules:
terraform_validate_test(
name = "validate_test",
target = ":my_module",
)
terraform_fmt_test(
name = "fmt_test",
target = ":my_module",
)
External dependencies and lock files
rules_terraform partitions external Terraform state into three buckets,
each with its own resolution path and (where applicable) lock file. Every
network fetch happens at repository-rule / bzlmod-extension time — build
actions never touch the network. What the aspect and Terraform actually
see at run time is a fully-populated .terraform/ tree.
At a glance
| Dependency type | Fetched from | Lock file | Refresh via |
|---|---|---|---|
| Providers | Terraform Registry v1 API | .terraform.lock.hcl | bazel run //path:providers_lock (a terraform_providers_lock target) — see below |
| Registry modules | Terraform Registry v1 API | none — live-resolved by the terraform.modules(...) / opentofu.modules(...) extension | Automatic on bazel fetch. See "External registry modules" below for the tradeoff. |
| Local / in-repo modules | Bazel target graph | none | Edit deps / module_sources on the parent terraform_module |
Providers
terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
terraform.providers(
name = "my_providers",
lock = "//path/to:.terraform.lock.hcl",
# Optional; default is `registry.terraform.io`. Use
# `registry.opentofu.org` if you're fetching from the OpenTofu registry.
# registry = "registry.opentofu.org",
)
use_repo(terraform, "my_providers")
The lock file is Terraform's native .terraform.lock.hcl — rules_terraform
consumes it, it doesn't author it.
At repo-rule time the extension:
- Reads the lock file via
module_ctx.read. - For every
(provider, platform)pair, callshttps://<registry>/v1/providers/<namespace>/<name>/<version>/download/<os>/<arch>viamodule_ctx.download(...)and reads the returned JSON to obtain the archivedownload_url+shasum. - Declares an
http_archiveper pair. These are lazy — Bazel only fetches the archive for the platform your build actually resolves. - Emits a hub repo
@my_providerswhose per-provider aliases pick the right platform archive viaselect(). Depend on@my_providers//<namespace>_<name>(e.g.@my_providers//hashicorp_null) from yourterraform_module.deps.
At build time terraform_init_aspect
extracts each provider's files into
.terraform/providers/<registry>/<namespace>/<name>/<version>/<platform>/
and recomputes the h1: hash in a copy of the lock file it writes inside
.terraform/. That rewrite is what lets terraform init -get=false
accept the Bazel-installed binaries.
Regenerating. Wire up a
terraform_providers_lock target
(or opentofu_providers_lock for
the OpenTofu variant):
load(
"@rules_terraform//terraform:terraform_modules_lock.bzl",
"terraform_providers_lock",
)
terraform_providers_lock(
name = "providers_lock",
output = ".terraform.lock.hcl",
target = ":my_module",
# Optional; defaults to a 5-platform set (linux/darwin ×
# amd64/arm64 + windows_amd64).
# platforms = ["linux_amd64", "darwin_arm64"],
)
bazel run //:providers_lock seeds a temp directory with your .tf
sources, runs real terraform providers lock -platform=<all> under the
toolchain-fetched engine binary, and writes the multi-platform result
back into $BUILD_WORKSPACE_DIRECTORY. This is the only path that
produces hashes for platforms Bazel didn't resolve for the current
build — plain terraform init on your laptop locks the current
platform only.
Drift check. terraform_providers_lock_test
fails if any provider declared in a required_providers { … } block
isn't in the lock file (or vice-versa). Pure text comparison — no
network. terraform_lock_diff_test
goes further: it structurally compares the init-aspect-generated
.terraform.lock.hcl against a checked-in golden produced by real
terraform init, catching cases where the aspect's rewrite doesn't
match what the engine would produce.
External registry modules
There is no lock file for registry modules — the extension resolves them live against the Registry API on every fresh evaluation. Terraform users load the extension from the terraform side:
terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
terraform.modules(
name = "my_modules",
root = "//path/to:main.tf", # any .tf file in the root module directory
)
use_repo(terraform, "my_modules")
OpenTofu users load the parallel extension — same tag class shape, but
the registry defaults to registry.opentofu.org:
opentofu = use_extension("@rules_terraform//opentofu:extensions.bzl", "opentofu")
opentofu.modules(
name = "my_modules",
root = "//path/to:main.tf",
)
use_repo(opentofu, "my_modules")
At extension eval time the impl reads every *.tf file in the same
directory as root (matching Terraform's own "root module is a
directory" model), parses module { source = "…" } blocks, and for
every registry-shaped source:
- Hits
/v1/modules/<ns>/<name>/<provider>/versionsand picks the highest version satisfying the block'sversionconstraint via a Starlark port of Terraform's semver logic. - Hits
/v1/modules/<ns>/<name>/<provider>/<version>for metadata — readssource(git URL) andtag, constructs a direct GitHub tarball URL (<source>/archive/refs/tags/<tag>.tar.gz) with the matchingstrip_prefix. - Downloads the archive to compute the
sha256. - Registers an
http_archiveper module.
Every downstream fetch goes direct to GitHub — no registry hop at build time. Only GitHub-backed modules are supported; non-GitHub git hosts fail with a clear error at eval time.
At build time the init aspect copies each module's files into
.terraform/modules/<key>/ and adds a corresponding entry to
Terraform's modules.json manifest.
Reproducibility and caching. The extension is
reproducible = False and every .tf edit in the root module
directory invalidates it (bzlmod tracks each module_ctx.read()).
Re-eval cost is cached across runs via module_ctx.facts — but that
cache only persists when MODULE.bazel.lock is enabled. See
Reproducibility for the full recipe and the
Implementation detail: network calls per eval note below.
Non-default registries. Pass registry = "…" on the tag class if
you're pointing at a private mirror or an alternate registry (e.g. a
Terraform user consuming the OpenTofu registry, or vice-versa).
Implementation detail: network calls per eval
For each module { } block, the extension issues up to three Registry
API requests, cached across evals via module_ctx.facts:
| Call | Purpose | Cost | Skipped when |
|---|---|---|---|
GET /v1/modules/<source>/versions | Resolve version constraint to a concrete SemVer | Small JSON | version is already an exact-pinned SemVer |
GET /v1/modules/<source>/<version> | Read source (git URL) + tag to build the archive URL | Small JSON | Facts cache has an entry for <source>@<version> |
GET <archive URL> | Stream the archive body to compute an sha256 integrity | MB per module | Facts cache has an entry for <source>@<version> |
Facts is keyed by <source>@<concrete_version> and stores
{url, strip_prefix, integrity} — historical facts that don't change
once a module version is published. Only entries for currently-declared
modules carry across evals; entries for removed modules prune naturally.
Local / in-repo modules
Modules living inside your monorepo don't need a lock file — Bazel's own target graph is the source of truth.
terraform_module(
name = "root",
srcs = glob(["*.tf"]),
deps = ["//modules/greeter"],
module_sources = {
# Terraform source path (as written in `module "…" { source = "…" }`)
# → Bazel target the init aspect should materialize into
# `.terraform/modules/<key>/`.
"./modules/greeter": "//modules/greeter",
},
)
The module_sources map translates each module "greeter" { source = "./modules/greeter" }
block into a Bazel-managed file copy. The sub-module can live in an
entirely different package — no filesystem-sibling relationship is
required, because the aspect copies files into .terraform/modules/…
based on the label, not on the original layout.
For modules that genuinely sit alongside the parent's .tf files (i.e.
source = "./foo" where ./foo really is a subdirectory of the
parent's own srcs), no module_sources entry is needed — the aspect
discovers the block and includes those files automatically.
Update workflow — one-page summary
| Change | What to do |
|---|---|
| Bump / add a provider | Update required_providers { }; regenerate .terraform.lock.hcl (real terraform providers lock or bazel run //:providers_lock) |
| Bump / add a registry module | Edit the module { source = "…" version = "…" } block. Live-resolved on the next bazel fetch; regenerate MODULE.bazel.lock if you have it enabled. |
| Add / rename a local module | Update deps and module_sources on the parent; no lock file involved |
| Verify everything | bazel test //... — runs validate, fmt, tftest, and every lock drift check in one shot |
Reproducibility
The state of each bzlmod extension in rules_terraform:
| Extension | Reproducible? | Why |
|---|---|---|
terraform_toolchains / opentofu_toolchains | ✓ | Every URL + integrity is vendored in //<engine>/private:versions.bzl. No network at eval time; identical inputs → identical repo declarations. |
terraform.providers(...) | ✓ | Reads a checked-in .terraform.lock.hcl; never resolves version constraints. Registry API is called to look up download URLs, but the version and hash come from the lock. |
terraform.modules(...) / opentofu.modules(...) | ✗ | Live version-constraint resolution against the Registry API. MODULE.bazel.lock is the capture layer that closes the gap — see below. |
The role of MODULE.bazel.lock
The modules extension is the only piece of rules_terraform that
depends on MODULE.bazel.lock for both reproducibility AND performance.
terraform_toolchains, opentofu_toolchains, and
terraform.providers(...) all read fully vendored state and produce
deterministic output without any lockfile involvement. The modules
extension is different in two related ways:
-
Reproducibility. Without the lockfile,
~> 5.0can resolve to5.7.1today and5.7.2tomorrow. With the lockfile enabled, bzlmod captures the extension's resolved output and reuses it on every subsequent eval — the extension isreproducible = Falseunder Bazel's contract, but the lockfile makes builds byte-identical across time in practice. -
Performance. Every
.tfedit invalidates the extension (bzlmod tracks eachmodule_ctx.read()). Without any cache, every re-eval hits the Registry API three times per module block, including downloading each archive to compute an sha256 — MB per module. The extension caches per-(source, version)archive URL + integrity inmodule_ctx.facts; those facts persist throughMODULE.bazel.lock. Without the lockfile, facts is always empty, and every re-eval pays the full cost.
Recommended downstream setup — in your consuming repo's .bazelrc:
common --lockfile_mode=update
# or, once you're confident in the state:
common --lockfile_mode=strict
Commit MODULE.bazel.lock. This gives you:
- Deterministic builds regardless of version constraints in
.tf. - Cheap re-evals — cached facts short-circuit metadata calls and archive downloads on every re-run.
- A PR-visible diff (
MODULE.bazel.lock) whenever a module version or archive hash actually changes.
Bonus: pin exact versions in module { } blocks. Combined with
MODULE.bazel.lock, exact pins let the extension skip even the
/versions API call — bringing warm-cache re-evals to zero network
calls per module:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.7.1" # not "~> 5.0"
}
Rules_terraform's own .bazelrc does NOT enable the lockfile
The ruleset ships with common --lockfile_mode=off. This is
deliberate for a rules repo: bumping any dev-only bazel_dep version
would produce lockfile churn in unrelated PRs. Downstream users are
recommended to enable it in their own repos.
Update workflow
To bump a module version with the lockfile enabled:
- Edit the exact version (or constraint) in the
module { }block. - Run
bazel mod deps --lockfile_mode=update(or justbazel fetch //...) to regenerateMODULE.bazel.lock. - Commit both the
.tfchange AND the lockfile diff — reviewers see exactly which archive changed.
Why not just mark the extension reproducible = True?
The extension makes live Registry API calls at eval time. Network
responses CAN vary across invocations (transient 5xx, mirror rotation,
registry outages, schema changes over years). Claiming
reproducible = True would be dishonest — same inputs, potentially
different outputs. MODULE.bazel.lock is Bazel's designed capture
layer for exactly this shape of extension, and setting
reproducible = True would tell bzlmod NOT to record the extension
in the lockfile — the opposite of what we want.
When could reproducible = True come back?
- If HashiCorp publishes a Terraform-level module lockfile schema that captures resolved versions + hashes ahead of extension eval.
- If rules_terraform introduces a mode where the extension consumes a user-supplied pinned-version list without hitting the network at all (essentially reintroducing a lockfile — the one we deliberately removed in favor of live resolution + facts caching).
Neither is planned. Until then, MODULE.bazel.lock + exact pins covers
the same ground with Bazel's native machinery.