CoursesOpenTofuEarly variable eval & provider functions

Early variable eval & provider functions

What OpenTofu can do that Terraform cannot.

Advanced12 min · lesson 10 of 12

A letter needs an address on the outside of the envelope. Whoever carries it reads that address before anyone opens the contents, because the contents cannot say where the letter is going. Your backend block is that address. It names the remote file where OpenTofu keeps state (the JSON, or JavaScript Object Notation, record of every resource this configuration manages, along with plenty of secrets sitting inside them). Variables live inside the envelope. Terraform reads the address first and the envelope second, so it bans variables in the backend outright, and bans them in a module's source and version too. OpenTofu 1.8 added an earlier reading pass that lifts the ban. Watch Terraform refuse first.

backend.tf
variable "env" {
type = string
}
terraform {
backend "s3" {
bucket = "acme-tofu-state"
key = "${var.env}/network.tfstate"
region = "us-east-1"
}
}
terminal
$ terraform init -input=false
output
Initializing the backend...
│ Error: Variables not allowed
│ on backend.tf line 8, in terraform:
│ 8: key = "${var.env}/network.tfstate"
│ Variables may not be used here.

Terraform is not being stubborn. The values that configure a backend cannot come from state, because the backend is the thing that tells you where state lives. Chicken, egg. The examples here use an S3 (Simple Storage Service, Amazon's object storage) bucket, though the same argument holds for every remote backend. The cost lands on you: one directory per environment, each carrying a near-identical backend block, kept in step by hand. The failure that follows is boringly common. Someone copies staging/ into prod/, updates nine lines and misses the tenth, and two environments now write to one state file. The first apply in the new directory cheerfully proposes deleting everything the other environment owns, because as far as that state file is concerned, those resources are no longer declared anywhere.

The static pass, and what it can see

Stagehands set the furniture before the actors walk on, working from a list that cannot mention anything an actor is carrying. OpenTofu runs a pass like that. Static evaluation happens before the dependency graph exists, and it resolves the things OpenTofu needs before it can talk to anything: the backend block, the encryption block (the state and plan encryption from the previous lesson), a module's source, and a module's version. Inside that pass, four kinds of reference work and nothing else: var.*, local.* (as long as the local is built only from other allowed values), path.* such as path.module and path.root, and terraform.*, where terraform.workspace resolves but terraform.applying does not, since nothing is applying yet. The docs put the rule plainly: these values "must be able to be resolved during tofu init before the state is available".

Everything else is refused, loudly, with a message that names both halves of the problem. Point a backend key at a data source and you get Dynamic value in static context, followed by the reference you wrote and the thing that needed it, like backend.s3. Reach for a module output and it is Module output not supported in static context. Call one of the provider functions from later in this lesson and it is Provider function in static context. None of these fail quietly at three in the morning. They fail at init, on your laptop, with the file and line printed.

main.tofu
variable "env" {
type = string
}
variable "vpc_version" {
type = string
}
locals {
state_key = "${lower(var.env)}/network.tfstate"
}
terraform {
backend "s3" {
bucket = "acme-tofu-state" # literal, on purpose
key = local.state_key
region = "us-east-1"
}
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws" # literal, on purpose
version = var.vpc_version
}
terminal
$ tofu init -var="env=STAGING" -var="vpc_version=5.13.0"
output
Initializing modules...
Downloading registry.opentofu.org/terraform-aws-modules/vpc/aws 5.13.0 for vpc...
- vpc in .terraform/modules/vpc
Initializing the backend...
Successfully configured the backend "s3"! OpenTofu will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Installing hashicorp/aws v5.70.0...
- Installed hashicorp/aws v5.70.0 (signed, key ID 0C0AF313E5FD9F80)
OpenTofu has been successfully initialized!

tofu init accepts -var and -var-file precisely because of this pass. lower(var.env) works because built-in functions are pure string work that depends on nothing outside the arguments you hand them. The file is called main.tofu, an extension OpenTofu reads and Terraform ignores completely, so OpenTofu-only syntax can live there without breaking anyone still running Terraform against the same repository. Notice what stayed literal: the bucket name and the module's registry address. That was deliberate, and the reason turns up two sections down.

Two passes over the same configuration
1tofu init
variables collected from env, tfvars, -var
2Static pass
only var, local, path, terraform resolve
3Backend + modules pinned
resolved config hashed into .terraform/
4State fetched
resources and data sources now exist
5plan / apply
full graph; provider:: functions run here
The arrows only run left to right. Anything on the right (a resource attribute, a data source, a provider function) can never feed anything on the left, which is why OpenTofu answers with "Dynamic value in static context" instead of guessing.

Check which state file you actually got

After init, OpenTofu leaves a receipt at .terraform/terraform.tfstate. Despite the name, that file is not your state. It records which backend was configured, the resolved settings, and a hash of them. It is the only artifact that tells you after the fact what the static pass really decided, which makes it the first thing to read when a run touched something you did not expect. jq (a command-line reader for JSON) turns it into a one-liner you can drop into a pipeline.

terminal
$ jq '.backend.type, .backend.config.bucket, .backend.config.key' \
.terraform/terraform.tfstate
output
"s3"
"acme-tofu-state"
"staging/network.tfstate"

That hash covers resolved values, not source text, so changing the variable changes the hash and OpenTofu notices. Run any command with a different env than you initialized with and it stops before touching a thing.

terminal
$ tofu plan -var="env=prod" -var="vpc_version=5.13.0"
output
│ Error: Backend initialization required, please run "tofu init"
│ Reason: Backend configuration block has changed
│ The "backend" is the interface that OpenTofu uses to store state,
│ perform operations, etc. If this message is showing up, it means that the
│ OpenTofu configuration you're using is using a custom configuration for
│ the OpenTofu backend.
│ Changes to backend configurations require reinitialization. This allows
│ OpenTofu to set up the new configuration, copy existing state, etc. Please run
│ "tofu init" with either the "-reconfigure" or "-migrate-state" flags to
│ use the current configuration.

That safety net exists only because .terraform/ was sitting there to disagree with. Delete the directory, land on a fresh CI (continuous integration, the automated worker that runs your pipeline on every commit) runner, or pass -reconfigure, whose documented job is to disregard any existing configuration. Now there is nothing to compare against, and whatever the variable says is where you go: successfully, and without complaint. Most pipelines start from a clean checkout and run init -reconfigure for exactly that reason, which means most pipelines have no safety net here at all.

Whoever writes the variable owns the state file

Take an ordinary setup. Continuous integration runs tofu plan on every pull request and posts the diff as a comment. The pipeline exports TF_VAR_env=staging, which feels like the safe, boring way to do it. Someone able to open a pull request adds one new file, zz.auto.tfvars, holding a single line. Files ending in .tfvars are plain lists of variable assignments, and OpenTofu loads every *.auto.tfvars file in the working directory without being asked. File values outrank environment variables. The full order, weakest first: TF_VAR_* environment variables, terraform.tfvars, terraform.tfvars.json, then *.auto.tfvars files in lexical order, then -var and -var-file on the command line. The environment variable your pipeline exported so carefully sits at the bottom of that list.

zz.auto.tfvars
env = "prod"
terminal
$ TF_VAR_env=staging tofu init -reconfigure > /dev/null
$ jq -r '.backend.config.key' .terraform/terraform.tfstate
output
prod/network.tfstate

The runner is fresh, so there is no cached backend to object, and init reports success. The plan that bot posts on the pull request is now production state measured against staging code, printing production resource attributes into a comment the author can read. Merge it and the apply offers to destroy everything production holds that the staging configuration does not declare. No vulnerability was exploited. No credential was stolen. One file was added to a branch.

TF_VAR_ is the weakest way to set an early-eval variable
Anything committed to the repository beats a TF_VAR_ environment variable, and *.auto.tfvars files load with no mention on the command line. Pass early-eval values as -var from the pipeline, since command-line values win outright, and treat any tracked tfvars file that sets a backend or module-version variable as a change to your state layout, reviewed like one. Remember that -reconfigure is exactly the flag that skips the change detection which would otherwise catch the switch.

The rule that holds up under pressure: keep the trust anchor literal. Bucket names, registry hostnames and provider source addresses never come from a variable. Only the leaf moves, a key prefix or a version string. Then back the configuration with credentials that cannot reach the other environment anyway. Give the staging runner's role an IAM (Identity and Access Management, the AWS service that decides who is allowed to do what) policy scoped to one prefix, so a repointed backend fails at the cloud layer no matter what the config says.

iam/ci-staging-state.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "StagingStateObjectsOnly",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::acme-tofu-state/staging/*"
},
{
"Sid": "ListStagingPrefixOnly",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-tofu-state",
"Condition": { "StringLike": { "s3:prefix": "staging/*" } }
}
]
}

One flag deserves its own paragraph. When the resolved backend changes, tofu init -migrate-state offers to copy your existing state to the new location, and it asks first. With -input=false it cannot ask, so it errors instead, which is the behaviour you want. The trouble starts when someone gets tired of that error and reaches for -force-copy, documented as suppressing those prompts and answering yes for you, and which turns on -migrate-state by itself. Pair -force-copy with a bucket name that came from a variable and you have a one-command export of production state to a destination an attacker picked. Keep the bucket literal, keep -force-copy out of automation, and do genuine backend moves by hand with a human watching.

Borrowing the provider's own functions

Your AWS provider already knows how to take an ARN (Amazon Resource Name, the long unique identifier AWS stamps on every resource) apart, because it does that thousands of times internally. Writing a regular expression to split one yourself is whittling your own tape measure while the manufacturer's sits in the box. OpenTofu 1.7 let providers hand you their functions directly, called as provider::<name>::<function>. The <name> is the local name from your required_providers block, not the vendor's, and a configured alias slots in as provider::<name>::<alias>::<function>. The function exists only after tofu init has downloaded that provider, and it belongs to the module that declared the provider, so a child module has to declare its own.

terminal
$ tofu console
> provider::aws::arn_parse("arn:aws:iam::444455556666:role/example")
output
{
"account_id" = "444455556666"
"partition" = "aws"
"region" = ""
"resource" = "role/example"
"service" = "iam"
}

Being honest about this one: provider functions are not OpenTofu-exclusive. OpenTofu shipped them in 1.7 and Terraform reached parity in its own 1.8. Early evaluation is the piece Terraform still lacks. The two features interlock in exactly one place, and this is the bit to keep in your head: a provider function can never feed the static pass, because no provider has been installed at that moment. Ask for one in a backend block and OpenTofu answers with Provider function in static context.

The defensive use is where this earns its keep. A pasted ARN is a trust decision wearing a string costume. If a role ARN in your variables belongs to account 444455556666 and you are account 111122223333, you have handed a stranger a seat at your table. That pattern has a name, the confused deputy: your pipeline holds real permissions and uses them on behalf of whoever wrote the input. A regular expression tells you the string looks like an ARN. arn_parse tells you whose it is, as typed data you can compare against your own account. Put the comparison in a lifecycle precondition, a rule OpenTofu checks before it will touch the resource, and a wrong account fails the plan. A check block would only raise a warning and let the apply carry on.

main.tofu
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
variable "deploy_role_arn" { type = string }
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket_policy" "artifacts" {
bucket = aws_s3_bucket.artifacts.id # bucket declared elsewhere in this module
policy = data.aws_iam_policy_document.allow_deploy.json
lifecycle {
precondition {
# the provider parses its own ARNs; no regex of yours to get wrong
condition = provider::aws::arn_parse(var.deploy_role_arn).account_id == data.aws_caller_identity.current.account_id
error_message = "deploy_role_arn is outside this account; refusing to grant it bucket access."
}
}
}
terminal
$ tofu plan -var='deploy_role_arn=arn:aws:iam::444455556666:role/example'
output
│ Error: Resource precondition failed
│ on main.tofu line 21, in resource "aws_s3_bucket_policy" "artifacts":
│ 21: condition = provider::aws::arn_parse(var.deploy_role_arn).account_id == data.aws_caller_identity.current.account_id
│ ├────────────────
│ │ data.aws_caller_identity.current.account_id is "111122223333"
│ │ var.deploy_role_arn is "arn:aws:iam::444455556666:role/example"
│ deploy_role_arn is outside this account; refusing to grant it bucket
│ access.

Keep secrets out of the address

Everything the static pass resolves gets written to disk in cleartext. Backend settings land in .terraform/terraform.tfstate, the resolved module source lands in .terraform/modules/modules.json, and CI systems love to cache and archive those directories between runs. Which makes this tempting pattern worse than it looks.

modules.tofu
# the token would be baked into .terraform/modules/modules.json
module "vpc" {
source = "git::https://oauth2:${var.git_token}@gitlab.example.com/acme/modules.git//vpc?ref=v1.20.4"
}
terminal
$ tofu init
output
Initializing modules...
│ Error: Sensitive value not allowed
│ on modules.tofu line 3, in module "vpc":
│ 3: source = "git::https://oauth2:${var.git_token}@gitlab.example.com/acme/modules.git//vpc?ref=v1.20.4"
│ Sensitive values, or values derived from sensitive values, cannot be used
│ as module.vpc.source.

OpenTofu blocks it because the variable is marked sensitive = true. Use a Git credential helper, an SSH (Secure Shell) deploy key, or a ~/.netrc entry instead, so the token never enters the configuration at all. OpenTofu 1.12 added a second lock on the same door: const in a variable declaration. const = true says the value must be computable without access to state, so a bad input fails at the declaration with a readable message rather than somewhere confusing later. const = false says the reverse, forbidding that variable from ever reaching a static context, and any attempt is met with The variable "git_token" cannot be used in a static context, because it is declared as "const = false".

variables.tofu
variable "env" {
type = string
const = true # must resolve during the static pass (OpenTofu 1.12+)
validation {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "env must be one of: dev, staging, prod."
}
}
variable "git_token" {
type = string
sensitive = true
const = false # never a backend, module source or module version
}

Keep the validation block for the readable failure it gives you, but do not make it your only gate on a backend value. The pipeline that supplies the variable is the right place to hold the allow-list, because that is the layer an attacker has to get past before OpenTofu ever starts.

Quick check
01Your pipeline exports TF_VAR_env=staging and runs tofu init -reconfigure on a fresh clone, where the backend key is "${var.env}/network.tfstate". A pull request adds a file zz.auto.tfvars containing env = "prod". Which state file does the run initialize against?
Incorrect — Backwards. TF_VAR_ variables are the weakest source in the precedence list, beaten by every tfvars file.
Correct — Auto-loaded tfvars sit above environment variables, and only -var / -var-file on the command line beat them.
Incorrect — That check compares against .terraform/terraform.tfstate, which does not exist on a fresh clone, so there is nothing to disagree with.
Incorrect — -reconfigure discards the cached backend and re-resolves from configuration. It ignores the previous backend, not your variables.
02During the static evaluation pass that runs at tofu init — the one that resolves the backend, encryption, and module source and version — which reference resolves successfully?
Incorrect — data sources do not exist yet in the static pass, so this fails with "Dynamic value in static context".
Incorrect — module outputs are unavailable that early, giving "Module output not supported in static context".
Incorrect — no provider is installed during the static pass, so this fails with "Provider function in static context".
Correct — the static pass resolves only var.*, local.*, path.*, and terraform.* values, and terraform.workspace is one of them.
03You guard an aws_s3_bucket_policy with a lifecycle precondition comparing provider::aws::arn_parse(var.deploy_role_arn).account_id to data.aws_caller_identity.current.account_id. Someone runs tofu plan with deploy_role_arn pointing at a role in a different AWS account. What happens?
Incorrect — preconditions are checked while building the plan, so the mismatch is caught at plan time.
Incorrect — provider functions run fine at plan and apply; they are only forbidden in the static pass at init.
Correct — arn_parse yields the typed account_id, the comparison is false, and a failed precondition is a hard stop that aborts the plan.
Incorrect — that is how a check block behaves; a lifecycle precondition blocks the run instead of warning.

One command tells you whether your repository already has this problem. Anything it prints is a tracked file that can move your state, so make the pipeline fail on it and hand env to OpenTofu with -var instead.

terminal
$ git ls-files '*.tfvars' '*.tfvars.json' \
| xargs -r grep -Hn '^[[:space:]]*env[[:space:]]*='
output
zz.auto.tfvars:1:env = "prod"

Try this

Run terraform init -input=false on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: tF_VAR_ is the weakest way to set an early-eval variable. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related