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.
Terraform Rules
Public rules
- terraform_binary —
bazel runwrapper for anyterraformsubcommand. - terraform_module — declares a
.tfmodule with providers and deps. - terraform_provider — declares a checked-in provider binary.
- terraform_provider_group — bundles providers with a shared lock file.
- terraform_test — runs Terraform's HCL native test framework.
- terraform_validate_test / aspect —
terraform validateas a Bazel test/aspect. - terraform_fmt_test / aspect —
terraform fmt -checkas a Bazel test/aspect. - terraform_modules_lock and drift/diff tests — keep lock files in sync with sources.
Module extensions
- extensions —
terraform_toolchains,terraform.
terraform_binary
Rules
terraform_binary
load("@rules_terraform//terraform:terraform_binary.bzl", "terraform_binary")
terraform_binary(name, lock, root)
Wraps the terraform binary for a terraform_module root. Invoke via
bazel run — any subcommand and args are passed straight through, so
bazel run //x:terraform -- plan, ... -- apply, ... -- state list, etc.
mirror the nominal CLI.
The .terraform directory is assembled hermetically by
terraform_init_aspect; providers and modules come from Bazel-managed
dependencies rather than the network.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| lock | An optional .terraform.lock.hcl file. | Label | optional | None |
| root | The terraform_module target that serves as the root module. | Label | required |
terraform_fmt_aspect
Aspects
terraform_fmt_aspect
load("@rules_terraform//terraform:terraform_fmt_aspect.bzl", "terraform_fmt_aspect")
terraform_fmt_aspect()
An aspect for running terraform fmt on targets with Terraform sources.
ASPECT ATTRIBUTES
ATTRIBUTES
terraform_fmt_test
Rules
terraform_fmt_test
load("@rules_terraform//terraform:terraform_fmt_test.bzl", "terraform_fmt_test")
terraform_fmt_test(name, target)
A rule for running terraform fmt on a Terraform target.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| target | - | Label | required |
terraform_module
Rules
terraform_module
load("@rules_terraform//terraform:terraform_module.bzl", "terraform_module")
terraform_module(name, deps, srcs, data, lock, main, module_sources)
Defines a Terraform module that can be used as a dependency in other Terraform targets.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | Other terraform_module, terraform_provider, terraform_provider_group, terraform_external_module, or terraform_module_group targets that this module depends on. | List of labels | optional | [] |
| srcs | Terraform source files (.tf) that make up this module. | List of labels | optional | [] |
| data | Additional files or targets that should be available at runtime. | List of labels | optional | [] |
| lock | An optional .terraform.lock.hcl file. | Label | optional | None |
| main | An explicit file to use for the entrypoint of the module. If unspecified, main.tf or the first .tf file will be used. | Label | optional | None |
| module_sources | Mapping of Terraform module source paths to Bazel target labels. Use this to map local module "foo" { source = "./modules/vpc" } references to Bazel targets from other packages or external bzlmod dependencies. Keys are the Terraform source paths, values are Bazel labels providing TerraformInfo. The init tool will symlink these at the expected paths. | Dictionary: String -> String | optional | {} |
Lock-file rules for .terraform.lock.hcl.
terraform_providers_lock regenerates the multi-platform lock file;
terraform_providers_lock_test catches drift against the module's
required_providers blocks; terraform_lock_diff_test structurally
diffs the init-aspect output against a checked-in golden.
External Terraform registry modules are resolved live by the
terraform.modules(...) bzlmod extension — no separate lock file, no
updater rule. See docs/src/index.md.
Rules
terraform_lock_diff_test
load("@rules_terraform//terraform:terraform_modules_lock.bzl", "terraform_lock_diff_test")
terraform_lock_diff_test(name, golden, regenerate_hint, target)
Diffs the init-aspect-generated .terraform.lock.hcl for
target against a checked-in golden produced by real
terraform init / tofu init. Verifies same provider set, same
version+constraints per provider, same zh: set, and at least one
h1: overlap.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| golden | A .terraform.lock.hcl produced by real terraform/tofu init. | Label | required | |
| regenerate_hint | Command shown in the failure message telling users how to refresh the golden. | String | optional | "" |
| target | A terraform_module target. The init aspect runs against it to produce the lock under test. | Label | required |
terraform_providers_lock
load("@rules_terraform//terraform:terraform_modules_lock.bzl", "terraform_providers_lock")
terraform_providers_lock(name, output, platforms, target)
Regenerates .terraform.lock.hcl for a terraform_module by running
real terraform providers lock -platform=<all> under the toolchain-fetched
terraform binary. bazel run writes the multi-platform result back to
source — the one path that produces hashes for platforms Bazel didn't
resolve for the current build.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| output | Where to write the multi-platform .terraform.lock.hcl, relative to this BUILD file's package. Diff-test companions should point golden = "<same-path>". | String | required | |
| platforms | os_arch platforms to record hashes for. Defaults to the common set. | List of strings | optional | [] |
| target | The terraform_module whose .tf sources drive the lock resolution. | Label | required |
terraform_providers_lock_test
load("@rules_terraform//terraform:terraform_modules_lock.bzl", "terraform_providers_lock_test")
terraform_providers_lock_test(name, lock, target)
Fails the test if .terraform.lock.hcl doesn't cover every provider
declared in terraform { required_providers { ... } } blocks reachable
from target (and vice versa). Network-free presence check.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| lock | The .terraform.lock.hcl file being verified. | Label | required | |
| target | The terraform_module whose transitive sources are being checked. | Label | required |
terraform_provider
Rules
terraform_provider
load("@rules_terraform//terraform:terraform_provider.bzl", "terraform_provider")
terraform_provider(name, files, platform, source, version)
Defines a Terraform provider that can be used as a dependency in terraform_module targets.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| files | The provider binary files. | List of labels | required | |
| platform | Platform string (e.g., 'linux_amd64', 'darwin_arm64'). If omitted, auto-detected at build time. | String | optional | "" |
| source | Provider source (e.g., 'hashicorp/null'). | String | required | |
| version | Provider version (e.g., '3.2.4'). | String | required |
terraform_provider_group
Rules
terraform_provider_group
load("@rules_terraform//terraform:terraform_provider_group.bzl", "terraform_provider_group")
terraform_provider_group(name, deps, lock)
Defines a group of Terraform providers with their lock file. This ensures all providers are from the same lock file.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | List of terraform_provider targets that belong to this group. | List of labels | required | |
| lock | The .terraform.lock.hcl file for this provider group. | Label | required |
terraform_test
Rules
terraform_test
load("@rules_terraform//terraform:terraform_test.bzl", "terraform_test")
terraform_test(name, lock, root)
Runs terraform's native HCL test framework (terraform test) on the root
module as a Bazel test. Test cases live in .tftest.hcl files. Fully
hermetic — the .terraform directory is assembled by the init aspect from
Bazel-managed deps.
Bazel --test_arg values are intentionally ignored so the surface stays
predictable — every run executes the module's .tftest.hcl files.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| lock | An optional .terraform.lock.hcl file. | Label | optional | None |
| root | The terraform_module target that serves as the root module. | Label | required |
terraform_validate_aspect
Aspects
terraform_validate_aspect
load("@rules_terraform//terraform:terraform_validate_aspect.bzl", "terraform_validate_aspect")
terraform_validate_aspect()
An aspect for running terraform validate on targets with Terraform sources.
ASPECT ATTRIBUTES
ATTRIBUTES
terraform_validate_test
Rules
terraform_validate_test
load("@rules_terraform//terraform:terraform_validate_test.bzl", "terraform_validate_test")
terraform_validate_test(name, target)
A rule for running terraform validate on a Terraform target.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| target | The target to validate. | Label | required |
Terraform Extensions
Two module extensions live in //terraform:extensions.bzl:
terraform_toolchains
No user-facing configuration. On evaluation it materializes one hub repo
(@terraform_toolchains) that declares a toolchain per (version, platform),
each guarded by a target_settings matching
//terraform/settings:version_<v>. rules_terraform's own MODULE.bazel
use_repos the hub and register_toolchainss it, so downstream users get
toolchain resolution for free — no ceremony beyond flipping
//terraform/settings:version to pick which Terraform
release to actually download. Per-(version, platform) http_archives are
lazy; only the selected combination is fetched.
# In your MODULE.bazel — nothing to do beyond `bazel_dep`:
bazel_dep(name = "rules_terraform", version = "...")
terraform
Carries providers and modules tag classes for users who want to pin
providers or registry-modules via Bazel-managed fetching.
terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
# One providers hub per `.terraform.lock.hcl` you want to fetch.
terraform.providers(
name = "my_providers",
lock = "//path/to/module:.terraform.lock.hcl",
# registry = "registry.opentofu.org", # optional; defaults to registry.terraform.io
)
# One modules hub per `terraform_modules.lock.json` (generated by lockgen).
terraform.modules(
name = "my_modules",
lock = "//path/to/module:terraform_modules.lock.json",
)
use_repo(terraform, "my_providers", "my_modules")
terraform.providers
| Attribute | Type | Description |
|---|---|---|
name | string (required) | Hub repository name. Each declared provider lives at @<name>//<namespace>_<name>. |
lock | label (required) | .terraform.lock.hcl file to fetch providers from. |
registry | string (default registry.terraform.io) | Registry host to query. |
terraform.modules
| Attribute | Type | Description |
|---|---|---|
name | string (required) | Hub repository name. Each fetched module is aggregated under @<name>//:<name>. |
lock | label (required) | terraform_modules.lock.json file (see terraform_modules_lock.md). |
Public build settings for //terraform.
Each exported function is named after a build setting in this package and its docstring describes what the setting does. The function body returns the setting's label so callers can reference it programmatically.
version
Picks which Terraform release the auto-registered toolchains resolve to.
rules_terraform ships one toolchain per (version, platform) combination
in TERRAFORM_VERSIONS, each guarded by a config_setting that
matches this flag; Bazel's toolchain resolver picks the matching one.
The corresponding http_archive is fetched lazily on first use, so
declaring dozens of versions does not slow down builds that only use
one.
Flip the flag globally in .bazelrc, per-invocation via
--@rules_terraform//terraform/settings:version=1.10.5, or per-target
with a Starlark configuration transition.
Defaults to the latest stable release listed in
//terraform/private:versions.bzl (TERRAFORM_DEFAULT_VERSION),
computed by tools/update_versions from the shipped versions table.
OpenTofu Rules
Everything under //opentofu mirrors //terraform but binds to
//opentofu:toolchain_type. Rule bodies are identical — only the
resolved toolchain differs — so behavior stays in lockstep between the
two engines. Data-only rules (opentofu_module, opentofu_provider,
opentofu_provider_group) are literal aliases for their terraform_*
counterparts, provided so an OpenTofu-only project never has to import
a terraform_* symbol.
Public rules
- opentofu_binary —
bazel runwrapper for anytofusubcommand. - opentofu_module — alias for
terraform_module. - opentofu_provider — alias for
terraform_provider. - opentofu_provider_group — alias for
terraform_provider_group. - opentofu_test — runs OpenTofu's HCL native test framework.
- opentofu_validate_test —
tofu validateas a Bazel test. - opentofu_fmt_test —
tofu fmt -checkas a Bazel test.
Module extensions
- extensions —
opentofu_toolchains.
opentofu_binary
Rules
opentofu_binary
load("@rules_terraform//opentofu:opentofu_binary.bzl", "opentofu_binary")
opentofu_binary(name, lock, root)
Wraps the opentofu binary for a terraform_module root. Invoke via
bazel run — any subcommand and args are passed straight through, so
bazel run //x:opentofu -- plan, ... -- apply, ... -- state list, etc.
mirror the nominal CLI.
The .terraform directory is assembled hermetically by
terraform_init_aspect; providers and modules come from Bazel-managed
dependencies rather than the network.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| lock | An optional .terraform.lock.hcl file. | Label | optional | None |
| root | The terraform_module target that serves as the root module. | Label | required |
opentofu_fmt_test
Rules
Aspects
opentofu_fmt_test
load("@rules_terraform//opentofu:opentofu_fmt_test.bzl", "opentofu_fmt_test")
opentofu_fmt_test(name, target)
A rule for running opentofu fmt on a Terraform target.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| target | - | Label | required |
opentofu_fmt_aspect
load("@rules_terraform//opentofu:opentofu_fmt_test.bzl", "opentofu_fmt_aspect")
opentofu_fmt_aspect()
An aspect for running opentofu fmt on targets with Terraform sources.
ASPECT ATTRIBUTES
ATTRIBUTES
opentofu_module — alias for terraform_module.
Same rule under both names: a bundle of .tf files + deps + optional
lock is engine-neutral, so opentofu_module is just a naming alias so
OpenTofu-only projects don't have to import a terraform_* symbol.
Rules
opentofu_module
load("@rules_terraform//opentofu:opentofu_module.bzl", "opentofu_module")
opentofu_module(name, deps, srcs, data, lock, main, module_sources)
Defines a Terraform module that can be used as a dependency in other Terraform targets.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | Other terraform_module, terraform_provider, terraform_provider_group, terraform_external_module, or terraform_module_group targets that this module depends on. | List of labels | optional | [] |
| srcs | Terraform source files (.tf) that make up this module. | List of labels | optional | [] |
| data | Additional files or targets that should be available at runtime. | List of labels | optional | [] |
| lock | An optional .terraform.lock.hcl file. | Label | optional | None |
| main | An explicit file to use for the entrypoint of the module. If unspecified, main.tf or the first .tf file will be used. | Label | optional | None |
| module_sources | Mapping of Terraform module source paths to Bazel target labels. Use this to map local module "foo" { source = "./modules/vpc" } references to Bazel targets from other packages or external bzlmod dependencies. Keys are the Terraform source paths, values are Bazel labels providing TerraformInfo. The init tool will symlink these at the expected paths. | Dictionary: String -> String | optional | {} |
opentofu_provider — alias for terraform_provider.
Same rule under both names: a checked-in provider binary declaration is
engine-neutral, so opentofu_provider is just a naming alias so
OpenTofu-only projects don't have to import a terraform_* symbol.
Rules
opentofu_provider
load("@rules_terraform//opentofu:opentofu_provider.bzl", "opentofu_provider")
opentofu_provider(name, files, platform, source, version)
Defines a Terraform provider that can be used as a dependency in terraform_module targets.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| files | The provider binary files. | List of labels | required | |
| platform | Platform string (e.g., 'linux_amd64', 'darwin_arm64'). If omitted, auto-detected at build time. | String | optional | "" |
| source | Provider source (e.g., 'hashicorp/null'). | String | required | |
| version | Provider version (e.g., '3.2.4'). | String | required |
opentofu_provider_group — alias for terraform_provider_group.
Same rule under both names.
Rules
opentofu_provider_group
load("@rules_terraform//opentofu:opentofu_provider_group.bzl", "opentofu_provider_group")
opentofu_provider_group(name, deps, lock)
Defines a group of Terraform providers with their lock file. This ensures all providers are from the same lock file.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | List of terraform_provider targets that belong to this group. | List of labels | required | |
| lock | The .terraform.lock.hcl file for this provider group. | Label | required |
opentofu_test
Rules
opentofu_test
load("@rules_terraform//opentofu:opentofu_test.bzl", "opentofu_test")
opentofu_test(name, lock, root)
Runs opentofu's native HCL test framework (opentofu test) on the root
module as a Bazel test. Test cases live in .tftest.hcl files. Fully
hermetic — the .terraform directory is assembled by the init aspect from
Bazel-managed deps.
Bazel --test_arg values are intentionally ignored so the surface stays
predictable — every run executes the module's .tftest.hcl files.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| lock | An optional .terraform.lock.hcl file. | Label | optional | None |
| root | The terraform_module target that serves as the root module. | Label | required |
opentofu_validate_test
Rules
Aspects
opentofu_validate_test
load("@rules_terraform//opentofu:opentofu_validate_test.bzl", "opentofu_validate_test")
opentofu_validate_test(name, target)
A rule for running opentofu validate on a Terraform target.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| target | The target to validate. | Label | required |
opentofu_validate_aspect
load("@rules_terraform//opentofu:opentofu_validate_test.bzl", "opentofu_validate_aspect")
opentofu_validate_aspect()
An aspect for running opentofu validate on targets with Terraform sources.
ASPECT ATTRIBUTES
ATTRIBUTES
OpenTofu Extensions
One module extension lives in //opentofu:extensions.bzl:
opentofu_toolchains
No user-facing configuration. Mirrors //terraform:extensions.bzl%terraform_toolchains
but resolves against //opentofu:toolchain_type. Materializes
@tofu_toolchains covering every supported OpenTofu version, each
toolchain guarded by a target_settings on
//opentofu/settings:version_<v>. rules_terraform's own
MODULE.bazel registers the hub so downstream users get toolchain
resolution for free.
# In your MODULE.bazel — nothing to do beyond `bazel_dep`:
bazel_dep(name = "rules_terraform", version = "...")
Providers and modules
Provider and external-module fetching is engine-neutral — use
//terraform:extensions.bzl%terraform for both engines. If you're
targeting OpenTofu, pass registry = "registry.opentofu.org" to
terraform.providers (or leave it defaulted to
registry.terraform.io, which OpenTofu also serves).
Public build settings for //opentofu.
Each exported function is named after a build setting in this package and its docstring describes what the setting does. The function body returns the setting's label so callers can reference it programmatically.
version
Picks which OpenTofu release the auto-registered toolchains resolve to.
rules_terraform ships one toolchain per (version, platform) combination
in TOFU_VERSIONS, each guarded by a config_setting that matches
this flag; Bazel's toolchain resolver picks the matching one. The
corresponding http_archive is fetched lazily on first use.
Flip the flag globally in .bazelrc, per-invocation via
--@rules_terraform//opentofu/settings:version=1.10.6, or per-target
with a Starlark configuration transition.
Defaults to the latest stable release listed in
//opentofu/private:versions.bzl (TOFU_DEFAULT_VERSION), computed
by tools/update_versions from the shipped versions table.