rules_terraform

Bazel rules for Terraform and OpenTofu. Every terraform_* rule has a tofu_* twin that takes the same inputs and runs the other engine; the text below says terraform and means both.

Setup

bazel_dep(name = "rules_terraform", version = "{version}")

Toolchains for both engines are registered by rules_terraform's own MODULE.bazel. A build uses one release per engine: the root module's toolchain tag, else the newest shipped in //terraform/private:versions.bzl / //tofu/private:versions.bzl, which carry the three newest releases with their checksums.

terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
terraform.toolchain(version = "1.9.8")

tofu = use_extension("@rules_terraform//tofu:extensions.bzl", "tofu")
tofu.toolchain(version = "1.9.1")

Any release works there. One outside the shipped table costs a single fetch of its SHA256SUMS, recorded as a fact in MODULE.bazel.lock. Changing the release is editing the tag; a tag in a dependency is ignored.

Nothing has to be installed on the host: the engine, provider archives and modules are all fetched by Bazel, and every bazel run and bazel test below uses the toolchain engine.

Getting started

A root module new to Bazel is a directory of .tf files. Three things get added around it: BUILD targets, a provider lock, and a deps tag in MODULE.bazel that fetches what the lock and the module blocks name. Gazelle writes the BUILD targets.

  1. Set up Gazelle with the rules' language and run it:

    # BUILD.bazel at the repository root
    load("@gazelle//:def.bzl", "gazelle", "gazelle_binary")
    
    gazelle_binary(
        name = "gazelle_bin",
        languages = ["@rules_terraform//terraform/gazelle"],
    )
    
    gazelle(
        name = "gazelle",
        gazelle = ":gazelle_bin",
    )
    

    bazel run //:gazelle writes a terraform_module per directory of configuration, deps between them following the local module blocks, and for a root (a directory with a backend or cloud block, or a # gazelle:tf_kind root directive) a terraform_binary named terraform and a terraform_providers_lock named providers_lock.update.

  2. Generate the lock and commit it. This runs the toolchain engine's providers lock for every platform in the default set and writes .terraform.lock.hcl next to the sources; it needs the network.

    bazel run //path/to/app:providers_lock.update
    

    Rerun it whenever a required_providers block changes.

  3. Declare the deps tag in MODULE.bazel. Gazelle names the hub a root depends on @tf_<path> with / and - replaced by _, or whatever a # gazelle:tf_deps_hub directive says; the tag must use that name.

    terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
    terraform.deps(
        name = "tf_path_to_app",
        lock = "//path/to/app:.terraform.lock.hcl",
        roots = ["//path/to/app:main.tf"],   # only if its `module` blocks name registry or git modules
    )
    use_repo(terraform, "tf_path_to_app")
    
  4. Run Gazelle again. With the lock in place it adds the terraform_providers_lock_test, which fails when the lock drifts from the sources. Then:

    bazel test //path/to/app/...                      # validate, fmt, lock drift
    bazel run //path/to/app:terraform -- plan         # the engine, offline for providers and modules
    

Without Gazelle the same targets are written by hand; the monorepo example shows them for three environments sharing one lock. Add .terraform/ to .gitignore: the rules read nothing from it, and init writes it.

Running the engine

terraform_binary

bazel run //path/to:terraform -- plan
bazel run //path/to:terraform -- apply

The binary copies the module directory the build assembled, .tf files, local child modules at their source paths, the lock and a populated .terraform/, into a temp dir and runs the engine there with the arguments given. The temp dir is removed when the command finishes. Relative file arguments (-out=tfplan, -var-file=x.tfvars) resolve against it, so pass absolute paths.

Because the build already installed the providers and the module manifest, the only thing a run-time init still contributes is backend setup, so the binary runs one only before subcommands that need a backend. version, fmt, validate, test and metadata skip it and work offline against a module with a remote backend.

State. A module with a backend or cloud block is unaffected: its state belongs to the backend. A module with neither keeps state where the native CLI keeps it, the working directory, which here is the temp dir, so by default state goes with the run: disposable_state on the terraform_module is True. A run that did write state is kept and its path printed, so the record of an apply never vanishes silently, but the next run starts fresh. Where state must outlive the run with no backend (bootstrapping the bucket that will hold real state, a sandbox kept for days), set disposable_state = False: the local backend is pointed at a per-module directory under bazel-out/rules_terraform/, which bazel clean deletes and which is named after every state-writing command. Setting it on a module that declares a backend is an error.

Environment. bazel run keeps the operator's environment, which is where credential helpers and TF_WORKSPACE live. The tests and the validate aspect run the engine with the CLI configuration disabled (TF_CLI_CONFIG_FILE pointed at an empty file, CHECKPOINT_DISABLE=1, TF_IN_AUTOMATION=1), so a ~/.terraformrc on one machine cannot change what a check reports.

Initializing in place

bazel run //path/to:terraform -- init is the one subcommand that does not run in the temp dir, where its work would be deleted with it. It installs what the build constructed into the module's source directory, so the native CLI works there afterwards.

bazel run //envs/prod/app:terraform -- init
cd envs/prod/app
terraform plan            # native CLI, providers and modules from the build

The directory is the root module's package under the checkout, wherever the command was run from; BUILD_WORKSPACE_DIRECTORY and the target decide it, not the shell's cwd (that is the @terraform hub's job). The runner:

  1. copies the built .terraform/providers/ and .terraform/modules/ over whatever is there, keeping .terraform/environment and the saved backend configuration, as a native re-init keeps them;
  2. writes .terraform.lock.hcl next to the sources only when the module has none of its own (it depends on a shared hub), and says so;
  3. adds the host platform's h1: hash to the lock for any installed provider the lock lacks it for, one line each, and says so. An unpacked package can only be verified by its h1:, so a lock made on another platform, or natively with fewer -platforms than the updater records, would make the engine refuse the package it is standing next to. It is the line providers lock -platform=<host> would add; commit it, or regenerate the lock for every platform with the updater;
  4. runs the engine's init -get=false -plugin-dir=.terraform/providers in that directory with the remaining arguments appended, which is where the backend gets configured.

Each init flag is either the backend's business, which passes through, or the build's, which is refused with the Bazel path named:

FlagUnder the binaryWhy
-backend-config=…, -backend=falsepasses throughBackend configuration is the module's, not the build's.
-reconfigurepasses throughRe-reads the backend block, ignoring the saved configuration. The staged runs are implicitly always reconfigured, since a fresh copy has none saved; in place it matters.
-migrate-state, -force-copypass throughMoves state from the previously saved backend configuration to the current one. It needs that saved configuration, which only a persisted directory has, so this is the only place under Bazel it can work.
-input, -lock, -lock-timeout, -no-color, -jsonpass throughPlain engine options.
-lockfile=readonlypasses throughForbids every lock write, step 3 included: a lock without the host's hash is then an error naming the updater rather than an edit.
-upgraderefusedWould re-resolve providers against the plugin dir and rewrite the lock. The versions are facts of the build: bazel run //path/to:providers_lock.update for providers, RULES_TERRAFORM_UPGRADE=1 bazel fetch for modules (see Upgrading).
-getrefusedThe build installs the modules; the manifest is theirs.
-plugin-dirrefusedThe build's providers are the plugin directory.
-from-modulerefusedA root module is a terraform_module target.
-chdir (global)refusedThe target names the module directory.
-helppasses throughAnswered by the engine, with nothing installed.

.terraform/ is the engine's cache and belongs in .gitignore; Gazelle and the rules ignore it, and a Bazel run never reads it. This is the one bazel run that writes into the source tree, and only because it was asked to. The next bazel run //path/to:terraform -- plan still starts from a fresh copy.

Workspaces

The rules add no workspace concept of their own; they use Terraform's. TF_WORKSPACE in the environment selects one for every project in a run: --action_env=TF_WORKSPACE=prod for the validate aspect, --test_env=TF_WORKSPACE=prod for the test rules, the shell for bazel run. A root whose configuration is keyed on terraform.workspace gets each of its workspaces checked by one test target per workspace, with the workspace attribute of terraform_validate_test, terraform_test or terraform_plan_test forcing it, and no_validate on the module where the default workspace is not a valid one. A workspace select inside a bazel run changes only the temp copy, so it does not carry to the next run and the runner says so; workspace new still creates the workspace in the backend.

Tests and review artefacts

terraform_validate_test and terraform_fmt_test are validate and fmt -check as Bazel tests. The same checks exist as aspects, which the examples' .bazelrc attaches so bazel build //... runs them over every module without a target per module; no_validate and no_format tags opt a module out.

terraform_test runs the engine's native test framework. Each run block is reported as a JUnit test case through XML_OUTPUT_FILE, so bazel test summaries show which run failed. Terraform writes the report itself with -junit-xml; for OpenTofu the runner builds it from the -json-into event stream and keeps that stream too.

terraform_plan_test runs a real plan and keeps plan.tfplan, plan.json and plan.txt as undeclared outputs under bazel-testlogs/<pkg>/<name>/test.outputs/, so a plan can be reviewed from CI artefacts. It passes when the plan succeeds, or with expect_no_changes only when the plan is empty. A plan reaches the backend and the provider APIs, so the target is tagged requires-network and credentials go through --test_env; providers that work offline (null, local, random) need neither. Plans hold sensitive values, so treat the artefacts as you would the state.

terraform_providers_lock_test fails when .terraform.lock.hcl does not cover what the module tree requires: every provider a required_providers block declares or a resource, data, ephemeral or provider block implies, in the root and in every installed module. No network, no engine.

The @terraform and @tofu hubs

Two repositories let you invoke the toolchain engine in whatever directory you are standing in, like the native CLI:

cd path/to/my/tf/module
bazel run @terraform -- providers lock
bazel run @tofu -- state list

The wrapper chdirs to $BUILD_WORKING_DIRECTORY before exec-ing the engine. It does no init aspect and no .terraform/ construction: a terraform init there runs against the source tree and hits the network. Reach for it for one-off CLI invocations against a directory with no terraform_binary. Downstream, bind the names:

terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
use_repo(terraform, "terraform")

bazel run @rules_terraform//terraform:format (or //tofu:format) rewrites the .tf files of every module in the workspace with the toolchain engine's fmt; pass target patterns to narrow it.

Dependencies

Every network fetch happens when the extension evaluates or a repository is fetched; build actions are offline, and what the engine sees at run time is a complete module directory. A terraform_module is one directory: srcs are the files the engine loads (.tf and .tf.json, plus .tofu and .tofu.json for tofu_module), all directly in the target's package, and anything else is an analysis error rather than a file the engine would silently skip. A nested directory of .tf files is a child module with its own target.

DependencyFetched fromPinned byRefresh
ProvidersRegistry v1 download API.terraform.lock.hclbazel run //path:providers_lock.update
Registry modulesRegistry v1 API, archives from GitHubthe version each constraint resolved to, a fact in MODULE.bazel.lockedit the block, or RULES_TERRAFORM_UPGRADE=1 bazel fetch //...
Git modulesGitHub archives over https; the host's git for any other remotethe ?ref=; what a tag or branch named is a fact in MODULE.bazel.lockedit the ?ref=, or RULES_TERRAFORM_UPGRADE=1 bazel fetch //... for a branch
Local modulesthe Bazel target graphnothingedit deps on the parent

The deps tag

One tag declares both halves of a root's external dependencies, because that is how the engine treats them: init installs the providers the lock pins and the modules the module blocks name in one step.

terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
terraform.deps(
    name = "my_deps",
    lock = "//path/to:.terraform.lock.hcl",       # optional: providers
    roots = ["//path/to:main.tf"],                 # optional: registry and git modules named in that directory
    # registry = "registry.opentofu.org",          # default follows the extension
)
use_repo(terraform, "my_deps")

@my_deps carries every provider in the lock, the lock itself, and every external module the roots name; a terraform_module that depends on it needs no lock attribute, and a maintainer who adds a provider edits required_providers and reruns the lock updater with no BUILD change. @my_deps//:providers, @my_deps//:modules and @my_deps//<namespace>_<name> are the parts. Archives are pooled across hubs, so two locks that agree on a version fetch it once, and only the platform a build resolves is fetched. The attributes are on the extension page.

Providers

The lock is Terraform's own .terraform.lock.hcl. The terraform_providers_lock updater writes it by running the toolchain engine's providers lock -platform=<all> over the sources, which is the one path that records h1: hashes for every platform at once; the engine's own init locks the platform it runs on only. The default set is linux_amd64, linux_arm64, darwin_amd64, darwin_arm64 and windows_amd64.

The extension asks the registry only where each archive lives; the archive's checksum must be one of the lock's zh: hashes, so the registry cannot change what is installed. A platform is left out of a hub only when the registry says the version was never published for it; every other failed lookup fails the evaluation, so an outage cannot leave a platform permanently missing from the lockfile. The build installs each provider under .terraform/providers/<registry>/<namespace>/<name>/<version>/<platform>/ and rewrites the h1: hashes in the tree's copy of the lock to the installed files, which is what lets the engine accept them.

Providers built in the repo

A provider that is not on any registry is a terraform_provider whose files is the built binary, used like a hub-provided one:

go_binary(
    name = "terraform-provider-custom",   # the engine requires this prefix
    srcs = ["main.go"],
)

terraform_provider(
    name = "custom",
    files = [":terraform-provider-custom"],
    source = "example/custom",             # matches `required_providers`
    version = "1.0.0",
)

terraform_module(
    name = "app",
    srcs = glob(["*.tf"]),
    lock = ".terraform.lock.hcl",
    deps = [":custom"],
)

Leave platform unset: it is read from the configuration the provider is built in. The lock still needs an entry for the provider, written by hand with the version and a placeholder h1:; the build replaces the hash with that of the binary it installed. A module that depends on providers but has no lock is an analysis error.

Registry modules

A module block whose source is a registry address is resolved when the extension evaluates: the highest version satisfying the block's constraint is chosen once and recorded as a fact in MODULE.bazel.lock, and every later evaluation declares it until an upgrade is asked for. List the directory in a deps tag's roots; every .tf file in it is scanned, and modules composed of other registry or git modules are followed.

terraform.deps(
    name = "shared",
    lock = "//:.terraform.lock.hcl",
    roots = [
        "//envs/prod/app:main.tf",
        "//modules/naming:main.tf",     # a library whose blocks name a registry module
    ],
)

Archives come straight from GitHub, which is where the public registries host their modules; a registry module hosted elsewhere is an error with the address, and is supplied by hand as below. A source written with its own hostname (app.terraform.io/acme/vpc/aws) is resolved against that host. OpenTofu users use the tofu extension's deps tag, which defaults to registry.opentofu.org.

Git modules

A module block whose source is a git address goes through the same roots scan with no registry involved, in the three spellings Terraform accepts: git::https://… or git::ssh://…, the scp-like git@host:org/repo.git, and the github.com/org/repo shorthand, each with an optional //subdir and ?ref=.

module "vpc" {
  source = "git::ssh://git@git.example.com/acme/terraform-modules.git//vpc?ref=v1.4.0"
}

What the ref pins. A commit id is exact. A tag is looked up once and what it names is recorded, so a moved tag is not followed. A branch, or no ?ref= meaning the default branch, is resolved to a commit the first time and recorded the same way: a choice, like a version constraint, that only RULES_TERRAFORM_UPGRADE moves. The native CLI follows such a source live on every init, so a tag or commit in the block is what keeps the two workflows on one revision.

GitHub over https needs no git: the extension fetches the archive GitHub serves for the ref, records its integrity, and declares an http_archive pinned by it. Every other remote, ssh:// to GitHub included, goes through the host's git: the ref is resolved with ls-remote, checked out once to record the commit and read the module's own references, and built as a new_git_repository of that commit, fetched with the host's credentials. git has to be on PATH when the extension evaluates and when the repository is fetched.

Not handled: ?sshkey=, submodules, and non-git sources such as plain https:// archives or s3::.

Modules the extension cannot fetch

Fetch the archive with any repository rule, declare a terraform_external_module in its build file with source copied verbatim from the module block, and put the target in deps; the build installs it under every block whose source matches.

# MODULE.bazel
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
    name = "acme_vpc",
    urls = ["https://git.example.com/acme/terraform-vpc/-/archive/v1.4.0/terraform-vpc-v1.4.0.tar.gz"],
    strip_prefix = "terraform-vpc-v1.4.0",
    integrity = "sha256-…",
    build_file_content = """
load("@rules_terraform//terraform:defs.bzl", "terraform_external_module")
terraform_external_module(
    name = "module",
    source = "https://git.example.com/acme/terraform-vpc/-/archive/v1.4.0/terraform-vpc-v1.4.0.tar.gz",
    srcs = glob(["**/*.tf"]),
    visibility = ["//visibility:public"],
)
""",
)

Local modules

A child module in the repository is its own terraform_module, listed in the parent's deps; the path lives in the module block alone.

module "greeter" {
  source = "./modules/greeter"
}
terraform_module(
    name = "root",
    srcs = glob(["*.tf"]),
    deps = ["//root/modules/greeter"],
)

A child whose package sits under the parent's lands at the path its package implies. Any other child is matched to the module block whose source names it by the tail of its package, so //shared/greeter satisfies source = "./greeter". A source no dep supplies, a dep no block references, and two deps that could satisfy one source are build errors.

Reproducibility

The extensions are reproducible = True: given the same tags, files and facts they declare the same repositories. Every answer taken from the network is a fact in MODULE.bazel.lock: a tagged engine release's checksums, where a provider archive lives, the version a module constraint resolved to, the commit a git ref named, an archive's integrity. A .tf edit under a roots directory re-evaluates the extension, but with warm facts that makes no network calls.

Facts persist only while the lockfile is enabled, so downstream:

common --lockfile_mode=update
# or, once confident:
common --lockfile_mode=strict

and commit MODULE.bazel.lock. That gives deterministic module versions however loosely the constraints are written, and a PR-visible diff whenever a resolution changes. --lockfile_mode=off resolves live on every evaluation, exactly as terraform init does. An exact pin in a module block is still worth writing: it is the only form the native init also treats as fixed.

Upgrading

A recorded resolution is kept on purpose. To move a constraint or a branch to what it resolves to today:

RULES_TERRAFORM_UPGRADE=1 bazel fetch //...   # or --repo_env=RULES_TERRAFORM_UPGRADE=1

Every constraint and git ref is resolved again and the facts rewritten; facts that are properties of a published version, such as an archive's integrity, are kept. Review the MODULE.bazel.lock diff and commit it. Editing a block's source, version or ?ref= is itself a new key, so a changed block resolves on the next bazel fetch without the switch.

Terraform Rules

Public rules

Module extensions

  • extensions — terraform: toolchain and deps tags.

terraform_binary

Rules

terraform_binary

load("@rules_terraform//terraform:terraform_binary.bzl", "terraform_binary")

terraform_binary(name, env, root, workspace)

Runs terraform on a terraform_module root. Arguments pass through: bazel run //x:terraform -- plan, -- apply, -- state list. The engine runs in a temp copy of the built module directory, providers and modules already installed, so nothing is fetched at run time.

init is the exception: it installs that directory into the module's source directory and initializes the backend there, so the native CLI works in that directory afterwards.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
envEnvironment variables for the engine, set over the host's for a run and over the hermetic one for a test: TF_VAR_<name> for a root keyed on an input variable, TF_LOG, TF_CLI_ARGS_plan. Values are literal. TF_WORKSPACE here and workspace must agree.Dictionary: String -> Stringoptional{}
rootThe terraform_module target that serves as the root module.Labelrequired
workspaceRun in this Terraform workspace, over anything the environment or --test_env sets. Unset, the environment's TF_WORKSPACE applies, else default. One test per workspace is how a root keyed on terraform.workspace gets each one checked, and one binary per workspace runs it with no workspace select first.Stringoptional""

terraform_external_module

Rules

terraform_external_module

load("@rules_terraform//terraform:terraform_external_module.bzl", "terraform_external_module")

terraform_external_module(name, srcs, source, subdir, version)

A module whose files come from outside the workspace.

The deps tag declares one per registry or git module it resolves. Declare one yourself, in the build_file_content of an http_archive or git_repository, for a module the extension cannot fetch. source must match the module block verbatim; list the target in the consuming terraform_module's deps.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
srcsEvery file of the module's repository or archive, not only the module's own: templates and other data the configuration reads, and the siblings a ../ source inside it names.List of labelsoptional[]
sourceThe source string exactly as the module blocks write it. The module is installed under every block whose source matches.Stringrequired
subdirThe module's directory inside srcs: the //subdir of the source, empty for the archive root.Stringoptional""
versionThe module's version, for a registry module. Leave it empty for a git or other source: the manifest only accepts a semantic version.Stringoptional""

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)

Fails when terraform fmt would change a module's sources.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
targetThe module to check.Labelrequired

terraform_module

Rules

terraform_module

load("@rules_terraform//terraform:terraform_module.bzl", "terraform_module")

terraform_module(name, deps, srcs, data, disposable_state, lock)

One directory of terraform configuration: a root module, or a child that another module's module block names.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
depsWhat the configuration references: child terraform_modules for its local module blocks, a dependency hub (@<name> from a deps tag, or its //:providers / //:modules halves) or single @<name>//<namespace>_<name> providers, and terraform_provider or terraform_external_module targets. The module block names a child's path; deps says which target supplies it. A child in a package under this one lands at its relative path; any other is matched to the block whose source ends with its package name.List of labelsoptional[]
srcsThe configuration files of this module: .tf, .tf.json, all directly in this target's package. A nested directory of configuration is its own module target, named by a module block.List of labelsoptional[]
dataAdditional files or targets that should be available at runtime.List of labelsoptional[]
disposable_stateWhether local state may go with the run, for a module with no backend or cloud block. True, the default: the engine keeps state in the run's temp directory, which is deleted afterwards; a run that wrote state is kept and its path printed. False: state is kept under bazel-out/rules_terraform/ across runs, until bazel clean. An error when the module declares a backend.BooleanoptionalTrue
lockThe module's .terraform.lock.hcl. Not needed when deps names a dependency hub, which carries its lock.LabeloptionalNone

terraform_plan_test

Functions

terraform_plan_test

load("@rules_terraform//terraform:terraform_plan_test.bzl", "terraform_plan_test")

terraform_plan_test(name, tags, **kwargs)

Runs terraform plan as a test and keeps the plan for review.

The rule of the same name, tagged requires-network: a plan reaches the backend and the provider APIs, with credentials from --test_env.

PARAMETERS

NameDescriptionDefault Value
name(str) Target name.none
tags(list[str]) Extra tags. requires-network is always added.[]
kwargsForwarded to the rule: root, plan_args, expect_no_changes, lock, and the usual test attributes.none

terraform_providers_lock

Rules

terraform_providers_lock

load("@rules_terraform//terraform:terraform_providers_lock.bzl", "terraform_providers_lock")

terraform_providers_lock(name, output, platforms, target)

bazel run target that writes a module's .terraform.lock.hcl with the toolchain terraform, recording hashes for every platform in platforms at once, which the engine's own init does not do. Needs the network.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
outputWhere to write the lock, relative to this package: .terraform.lock.hcl.Stringrequired
platformsPlatforms to record hashes for, as linux_amd64. Default: linux_amd64, linux_arm64, darwin_amd64, darwin_arm64, windows_amd64.List of stringsoptional[]
targetThe terraform_module the lock covers, its local child modules included.Labelrequired

terraform_providers_lock_test

Rules

terraform_providers_lock_test

load("@rules_terraform//terraform:terraform_providers_lock_test.bzl", "terraform_providers_lock_test")

terraform_providers_lock_test(name, lock, target)

Fails when .terraform.lock.hcl does not match what the module tree requires: every module the init aspect placed, root, local children and installed external ones, and in each the providers required_providers declares plus the ones resource, data, ephemeral and provider blocks imply. A provider the configuration needs but the lock lacks fails, and so does a lock entry nothing needs. Network-free; no engine runs.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
lockThe .terraform.lock.hcl file being verified.Labelrequired
targetThe terraform_module whose module tree is being checked.Labelrequired

terraform_provider

Rules

terraform_provider

load("@rules_terraform//terraform:terraform_provider.bzl", "terraform_provider")

terraform_provider(name, files, platform, source, version)

A provider built in this repository, for a terraform_module's deps. Registry providers come from a deps tag's hub instead.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
filesThe provider binary and any files shipped with it.List of labelsrequired
platformThe platform, as linux_amd64. Leave it empty for a provider built here: it is taken from the configuration the provider is built in.Stringoptional""
sourceThe provider address as required_providers writes it, e.g. hashicorp/null.Stringrequired
versionThe provider version, e.g. 3.2.4.Stringrequired

terraform_test

Rules

terraform_test

load("@rules_terraform//terraform:terraform_test.bzl", "terraform_test")

terraform_test(name, env, env_inherit, root, workspace)

Runs terraform test on the root module's .tftest.hcl files as a Bazel test. Each run block is a JUnit test case in Bazel's XML report, and the lock and module manifest the test ran against are kept as undeclared test outputs. --test_arg is ignored.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
envEnvironment variables for the engine, set over the host's for a run and over the hermetic one for a test: TF_VAR_<name> for a root keyed on an input variable, TF_LOG, TF_CLI_ARGS_plan. Values are literal. TF_WORKSPACE here and workspace must agree.Dictionary: String -> Stringoptional{}
env_inheritVariables the test takes from the environment bazel test ran in, AWS_PROFILE say. The variables tests scrub for hermeticity (the CLI configuration ones) cannot be inherited, and the runner says so.List of stringsoptional[]
rootThe terraform_module target that serves as the root module.Labelrequired
workspaceRun in this Terraform workspace, over anything the environment or --test_env sets. Unset, the environment's TF_WORKSPACE applies, else default. One test per workspace is how a root keyed on terraform.workspace gets each one checked, and one binary per workspace runs it with no workspace select first.Stringoptional""

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, env, env_inherit, target, workspace)

A rule for running terraform validate on a Terraform target.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
envEnvironment variables for the engine, set over the host's for a run and over the hermetic one for a test: TF_VAR_<name> for a root keyed on an input variable, TF_LOG, TF_CLI_ARGS_plan. Values are literal. TF_WORKSPACE here and workspace must agree.Dictionary: String -> Stringoptional{}
env_inheritVariables the test takes from the environment bazel test ran in, AWS_PROFILE say. The variables tests scrub for hermeticity (the CLI configuration ones) cannot be inherited, and the runner says so.List of stringsoptional[]
targetThe target to validate.Labelrequired
workspaceRun in this Terraform workspace, over anything the environment or --test_env sets. Unset, the environment's TF_WORKSPACE applies, else default. One test per workspace is how a root keyed on terraform.workspace gets each one checked, and one binary per workspace runs it with no workspace select first.Stringoptional""

Terraform Extensions

One module extension lives in //terraform:extensions.bzl, terraform, with two tag classes.

terraform.toolchain

The extension always materializes one hub repo (@terraform_toolchains) that declares a toolchain per platform for one Terraform release, and @terraform, the CLI hub. rules_terraform's own MODULE.bazel use_repos the toolchain hub and register_toolchainss it, so downstream users get toolchain resolution for free. Per-platform http_archives are lazy; only the resolved platform is fetched.

The release is the root module's toolchain tag, else the newest shipped in //terraform/private:versions.bzl (the three newest when the table was generated carry checksums). For any other release the extension fetches its SHA256SUMS once and records the checksums as facts in MODULE.bazel.lock.

# Nothing to do beyond `bazel_dep` to use the newest shipped release.
bazel_dep(name = "rules_terraform", version = "...")

# To use another release:
terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")
terraform.toolchain(version = "1.9.8")

terraform.toolchain

AttributeTypeDescription
versionstring (required)The release, e.g. 1.9.8.

Root module only, at most one tag; tags in dependencies are ignored. A build has one engine release, and changing it is editing the tag.

terraform.deps

The deps tag class: the providers a lock file pins and the registry and git modules some directories' module blocks name, as one hub per tag.

terraform = use_extension("@rules_terraform//terraform:extensions.bzl", "terraform")

# One `deps` tag per lock file. `lock` brings the providers the lock pins;
# `roots` brings the registry and git modules named by the `module` blocks
# in those directories (root modules and shared libraries alike). Either may
# be omitted.
terraform.deps(
    name = "app",
    lock = "//path/to/module:.terraform.lock.hcl",
    roots = [
        "//path/to/module:main.tf",
        "//path/to/library:main.tf",
    ],
    # registry = "registry.opentofu.org",  # optional; defaults to registry.terraform.io
)

use_repo(terraform, "app")

@app is a terraform_dependency_group: every provider in the lock, the lock itself, and every external module the roots name. A terraform_module that depends on it needs no lock attribute. @app//:providers and @app//:modules are the two halves, and @app//<namespace>_<name> is one provider. Name the hub after what it serves rather than after the tag.

Hubs are alias layers. The archives themselves go into pools shared by every hub of the extension: one repository per distinct provider archive (rt_prov_<registry>_<namespace>_<name>_<version>_<platform>) and one per distinct module archive (rt_mod_<registry>_<address>_<version>), so two hubs that agree on a version fetch and extract it once. Hub names share one namespace across every Bazel module that uses the extension; a duplicate is reported at evaluation time.

terraform.deps

AttributeTypeDescription
namestring (required)Hub repository name.
locklabelA .terraform.lock.hcl. Every provider it pins is fetched for every platform the registry publishes.
rootslabel_listDirectories whose module { source = "…" } blocks name registry or git modules (git::…, git@…, github.com/…, ?ref= a tag, a full commit id or a branch; a branch, or no ref, resolves to a commit once and is kept as a fact), each as any .tf file inside it. Every *.tf in each directory is scanned; modules composed of other registry or git modules are followed. GitHub https remotes are fetched as integrity-pinned archives with no git involved; other remotes go through the host's git.
rootlabelShorthand for a one-element roots.
registrystring (default empty)Registry host to query for lock entries and sources that name none. Empty means registry.terraform.io from this extension, registry.opentofu.org from the OpenTofu one.

OpenTofu Rules

Everything under //tofu mirrors //terraform but binds to //tofu:toolchain_type. Rule bodies are identical — only the resolved toolchain differs — so behavior stays in lockstep between the two engines. Data-only rules (tofu_module, tofu_external_module, tofu_provider, tofu_provider_group) are literal aliases for their terraform_* counterparts, provided so an OpenTofu-only project never has to import a terraform_* symbol.

Public rules

Module extensions

tofu_binary

Rules

tofu_binary

load("@rules_terraform//tofu:tofu_binary.bzl", "tofu_binary")

tofu_binary(name, env, root, workspace)

Runs tofu on a terraform_module root. Arguments pass through: bazel run //x:tofu -- plan, -- apply, -- state list. The engine runs in a temp copy of the built module directory, providers and modules already installed, so nothing is fetched at run time.

init is the exception: it installs that directory into the module's source directory and initializes the backend there, so the native CLI works in that directory afterwards.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
envEnvironment variables for the engine, set over the host's for a run and over the hermetic one for a test: TF_VAR_<name> for a root keyed on an input variable, TF_LOG, TF_CLI_ARGS_plan. Values are literal. TF_WORKSPACE here and workspace must agree.Dictionary: String -> Stringoptional{}
rootThe terraform_module target that serves as the root module.Labelrequired
workspaceRun in this Terraform workspace, over anything the environment or --test_env sets. Unset, the environment's TF_WORKSPACE applies, else default. One test per workspace is how a root keyed on terraform.workspace gets each one checked, and one binary per workspace runs it with no workspace select first.Stringoptional""

tofu_external_module — alias for terraform_external_module.

Same rule under both names: a module fetched from outside the workspace is engine-neutral, so tofu_external_module is just a naming alias so OpenTofu-only projects don't have to import a terraform_* symbol.

Rules

tofu_external_module

load("@rules_terraform//tofu:tofu_external_module.bzl", "tofu_external_module")

tofu_external_module(name, srcs, source, subdir, version)

A module whose files come from outside the workspace.

The deps tag declares one per registry or git module it resolves. Declare one yourself, in the build_file_content of an http_archive or git_repository, for a module the extension cannot fetch. source must match the module block verbatim; list the target in the consuming terraform_module's deps.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
srcsEvery file of the module's repository or archive, not only the module's own: templates and other data the configuration reads, and the siblings a ../ source inside it names.List of labelsoptional[]
sourceThe source string exactly as the module blocks write it. The module is installed under every block whose source matches.Stringrequired
subdirThe module's directory inside srcs: the //subdir of the source, empty for the archive root.Stringoptional""
versionThe module's version, for a registry module. Leave it empty for a git or other source: the manifest only accepts a semantic version.Stringoptional""

tofu_fmt_test

Rules

Aspects

tofu_fmt_test

load("@rules_terraform//tofu:tofu_fmt_test.bzl", "tofu_fmt_test")

tofu_fmt_test(name, target)

Fails when tofu fmt would change a module's sources.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
targetThe module to check.Labelrequired

tofu_fmt_aspect

load("@rules_terraform//tofu:tofu_fmt_test.bzl", "tofu_fmt_aspect")

tofu_fmt_aspect()

An aspect for running tofu fmt on targets with Terraform sources.

ASPECT ATTRIBUTES

ATTRIBUTES

tofu_providers_lock

Rules

tofu_providers_lock

load("@rules_terraform//tofu:tofu_providers_lock.bzl", "tofu_providers_lock")

tofu_providers_lock(name, output, platforms, target)

bazel run target that writes a module's .terraform.lock.hcl with the toolchain tofu, recording hashes for every platform in platforms at once, which the engine's own init does not do. Needs the network.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
outputWhere to write the lock, relative to this package: .terraform.lock.hcl.Stringrequired
platformsPlatforms to record hashes for, as linux_amd64. Default: linux_amd64, linux_arm64, darwin_amd64, darwin_arm64, windows_amd64.List of stringsoptional[]
targetThe terraform_module the lock covers, its local child modules included.Labelrequired

tofu_providers_lock_test

Rules

tofu_providers_lock_test

load("@rules_terraform//tofu:tofu_providers_lock_test.bzl", "tofu_providers_lock_test")

tofu_providers_lock_test(name, lock, target)

Fails when .terraform.lock.hcl does not match what the module tree requires: every module the init aspect placed, root, local children and installed external ones, and in each the providers required_providers declares plus the ones resource, data, ephemeral and provider blocks imply. A provider the configuration needs but the lock lacks fails, and so does a lock entry nothing needs. Network-free; no engine runs.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
lockThe .terraform.lock.hcl file being verified.Labelrequired
targetThe terraform_module whose module tree is being checked.Labelrequired

tofu_module

The same rule body as terraform_module, bound to OpenTofu: srcs also accepts .tofu and .tofu.json, which Terraform would ignore.

Rules

tofu_module

load("@rules_terraform//tofu:tofu_module.bzl", "tofu_module")

tofu_module(name, deps, srcs, data, disposable_state, lock)

One directory of tofu configuration: a root module, or a child that another module's module block names.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
depsWhat the configuration references: child terraform_modules for its local module blocks, a dependency hub (@<name> from a deps tag, or its //:providers / //:modules halves) or single @<name>//<namespace>_<name> providers, and terraform_provider or terraform_external_module targets. The module block names a child's path; deps says which target supplies it. A child in a package under this one lands at its relative path; any other is matched to the block whose source ends with its package name.List of labelsoptional[]
srcsThe configuration files of this module: .tf, .tf.json, .tofu, .tofu.json, all directly in this target's package. A nested directory of configuration is its own module target, named by a module block.List of labelsoptional[]
dataAdditional files or targets that should be available at runtime.List of labelsoptional[]
disposable_stateWhether local state may go with the run, for a module with no backend or cloud block. True, the default: the engine keeps state in the run's temp directory, which is deleted afterwards; a run that wrote state is kept and its path printed. False: state is kept under bazel-out/rules_terraform/ across runs, until bazel clean. An error when the module declares a backend.BooleanoptionalTrue
lockThe module's .terraform.lock.hcl. Not needed when deps names a dependency hub, which carries its lock.LabeloptionalNone

tofu_plan_test

Functions

tofu_plan_test

load("@rules_terraform//tofu:tofu_plan_test.bzl", "tofu_plan_test")

tofu_plan_test(name, tags, **kwargs)

Runs tofu plan as a test and keeps the plan for review.

The rule of the same name, tagged requires-network: a plan reaches the backend and the provider APIs, with credentials from --test_env.

PARAMETERS

NameDescriptionDefault Value
name(str) Target name.none
tags(list[str]) Extra tags. requires-network is always added.[]
kwargsForwarded to the rule: root, plan_args, expect_no_changes, lock, and the usual test attributes.none

tofu_provider — alias for terraform_provider.

Same rule under both names: a checked-in provider binary declaration is engine-neutral, so tofu_provider is just a naming alias so OpenTofu-only projects don't have to import a terraform_* symbol.

Rules

tofu_provider

load("@rules_terraform//tofu:tofu_provider.bzl", "tofu_provider")

tofu_provider(name, files, platform, source, version)

A provider built in this repository, for a terraform_module's deps. Registry providers come from a deps tag's hub instead.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
filesThe provider binary and any files shipped with it.List of labelsrequired
platformThe platform, as linux_amd64. Leave it empty for a provider built here: it is taken from the configuration the provider is built in.Stringoptional""
sourceThe provider address as required_providers writes it, e.g. hashicorp/null.Stringrequired
versionThe provider version, e.g. 3.2.4.Stringrequired

tofu_test

Rules

tofu_test

load("@rules_terraform//tofu:tofu_test.bzl", "tofu_test")

tofu_test(name, env, env_inherit, root, workspace)

Runs tofu test on the root module's .tftest.hcl files as a Bazel test. Each run block is a JUnit test case in Bazel's XML report, and the lock and module manifest the test ran against are kept as undeclared test outputs. --test_arg is ignored.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
envEnvironment variables for the engine, set over the host's for a run and over the hermetic one for a test: TF_VAR_<name> for a root keyed on an input variable, TF_LOG, TF_CLI_ARGS_plan. Values are literal. TF_WORKSPACE here and workspace must agree.Dictionary: String -> Stringoptional{}
env_inheritVariables the test takes from the environment bazel test ran in, AWS_PROFILE say. The variables tests scrub for hermeticity (the CLI configuration ones) cannot be inherited, and the runner says so.List of stringsoptional[]
rootThe terraform_module target that serves as the root module.Labelrequired
workspaceRun in this Terraform workspace, over anything the environment or --test_env sets. Unset, the environment's TF_WORKSPACE applies, else default. One test per workspace is how a root keyed on terraform.workspace gets each one checked, and one binary per workspace runs it with no workspace select first.Stringoptional""

tofu_validate_test

Rules

Aspects

tofu_validate_test

load("@rules_terraform//tofu:tofu_validate_test.bzl", "tofu_validate_test")

tofu_validate_test(name, env, env_inherit, target, workspace)

A rule for running tofu validate on a Terraform target.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
envEnvironment variables for the engine, set over the host's for a run and over the hermetic one for a test: TF_VAR_<name> for a root keyed on an input variable, TF_LOG, TF_CLI_ARGS_plan. Values are literal. TF_WORKSPACE here and workspace must agree.Dictionary: String -> Stringoptional{}
env_inheritVariables the test takes from the environment bazel test ran in, AWS_PROFILE say. The variables tests scrub for hermeticity (the CLI configuration ones) cannot be inherited, and the runner says so.List of stringsoptional[]
targetThe target to validate.Labelrequired
workspaceRun in this Terraform workspace, over anything the environment or --test_env sets. Unset, the environment's TF_WORKSPACE applies, else default. One test per workspace is how a root keyed on terraform.workspace gets each one checked, and one binary per workspace runs it with no workspace select first.Stringoptional""

tofu_validate_aspect

load("@rules_terraform//tofu:tofu_validate_test.bzl", "tofu_validate_aspect")

tofu_validate_aspect()

An aspect for running tofu validate on targets with Terraform sources.

ASPECT ATTRIBUTES

ATTRIBUTES

OpenTofu Extensions

One module extension lives in //tofu:extensions.bzl, tofu, with the same two tag classes as the terraform extension.

tofu.toolchain

The extension always materializes @tofu_toolchains, a hub declaring a toolchain per platform for one OpenTofu release, the root module's toolchain tag or else the newest shipped in //tofu/private:versions.bzl, and @tofu, the CLI hub. rules_terraform's own MODULE.bazel registers the hub so downstream users get toolchain resolution for free.

# Nothing to do beyond `bazel_dep` to use the newest shipped release.
bazel_dep(name = "rules_terraform", version = "...")

# To use another release:
tofu = use_extension("@rules_terraform//tofu:extensions.bzl", "tofu")
tofu.toolchain(version = "1.9.1")

tofu.toolchain takes the same version attribute as the Terraform extension's tag, with the same root-only rule; see the Terraform page. For a tagged release the extension fetches that release's SHA256SUMS once and records the checksums as facts in MODULE.bazel.lock.

tofu.deps

The same deps tag class as the terraform extension, defaulting to registry.opentofu.org. An OpenTofu-only repository uses it for everything and never loads //terraform:extensions.bzl:

tofu = use_extension("@rules_terraform//tofu:extensions.bzl", "tofu")
tofu.deps(
    name = "app",
    lock = "//path/to/module:.terraform.lock.hcl",   # written by tofu
    roots = ["//path/to/module:main.tf"],
)
use_repo(tofu, "app")

The attributes are those documented for the terraform extension; only the default registry differs. That default matters: registry.opentofu.org serves OpenTofu's own builds of the hashicorp/* providers, whose archive hashes differ from HashiCorp's, so a lock file written by tofu has to be resolved against it. Write the lock with the module's tofu_providers_lock updater and it will be.

Each extension pools its own archives. A repository using both engines against the same dependencies should declare them through one extension rather than both, or the same archive is fetched twice.

Gazelle

rules_terraform ships a Gazelle language per engine. It writes the BUILD files the rules need from the configuration itself: one module target per directory of .tf files, with deps kept in step with the module blocks, and the binary and lock targets for a root.

Setup

# BUILD.bazel at the repository root
load("@gazelle//:def.bzl", "gazelle", "gazelle_binary")

gazelle_binary(
    name = "gazelle_bin",
    languages = [
        "@rules_terraform//terraform/gazelle",
        # "@rules_terraform//tofu/gazelle",  # for OpenTofu directories
    ],
)

gazelle(
    name = "gazelle",
    gazelle = ":gazelle_bin",
)

bazel run //:gazelle then writes or updates BUILD files; bazel run //:gazelle -- -mode diff shows what it would change, which is what a CI check runs.

What a directory becomes

The directoryGenerated
has configuration filesterraform_module(name = <directory name>, srcs = [...], data = [...], deps = [...])
also declares a backend or cloud blockplus terraform_binary(name = "terraform") and terraform_providers_lock(name = "providers_lock.update")
also has a .terraform.lock.hclplus terraform_providers_lock_test(name = "providers_lock"), and the updater if the directory is not a root
holds .tofu filesthe tofu_* kinds instead

srcs are the files the engine loads, .tf and .tf.json, plus .tofu and .tofu.json under OpenTofu. data is everything else a native run in that directory could read: the remaining files, and the contents of subdirectories that are not packages of their own. Build files, the lock, state, and dotfiles are left out, as is anything under .terraform/.

deps has two parts. Every module block with a local source (./… or ../…) resolves to the module target in that directory; a directory nothing generates a target for is reported. Then the dependency hubs: the ones a tf_deps_hub directive names, or, for a root with a lock file and no directive, @tf_<path> with / and - replaced by _.

Rules are merged, not overwritten: a hand-written tags, visibility, or disposable_state stays, comments stay, and an attribute written as a glob() is left alone with a warning, since a list cannot be merged into it. Delete the glob to let Gazelle write the list.

Directives

DirectiveMeaning
# gazelle:tf_engine terraform|tofuWhich engine's rules a directory tree gets. Unset, a directory with .tofu files is OpenTofu's, the rest Terraform's.
# gazelle:tf_kind root|module|autoForce a root (binary and lock targets) or a library. auto, the default, reads the configuration for a backend.
# gazelle:tf_deps_hub NAME...The hubs (@NAME) modules under the directory depend on. An empty value clears an inherited one.
# gazelle:tf_default_visibility LABEL...Visibility written on new library modules under the directory. An existing rule keeps its own.

Directives inherit down the tree, so one tf_deps_hub on a module library's parent directory covers every module in it, and one tf_engine tofu at the repository root makes an OpenTofu-only repository.

What it does not do

It does not write .terraform.lock.hcl; that is providers_lock.update. It does not declare the deps tags in MODULE.bazel; the hub names it writes are the names those tags must have. And it does not read expressions: a module block whose source is not a literal is not resolved.