Early variable eval & provider functions
What OpenTofu can do that Terraform cannot.
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.
variable "env" {type = string}terraform {backend "s3" {bucket = "acme-tofu-state"key = "${var.env}/network.tfstate"region = "us-east-1"}}
$ terraform init -input=false
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.
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 purposekey = local.state_keyregion = "us-east-1"}}module "vpc" {source = "terraform-aws-modules/vpc/aws" # literal, on purposeversion = var.vpc_version}
$ tofu init -var="env=STAGING" -var="vpc_version=5.13.0"
Initializing modules...Downloading registry.opentofu.org/terraform-aws-modules/vpc/aws 5.13.0 for vpc...- vpc in .terraform/modules/vpcInitializing the backend...Successfully configured the backend "s3"! OpenTofu will automaticallyuse 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.
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.
$ jq '.backend.type, .backend.config.bucket, .backend.config.key' \.terraform/terraform.tfstate
"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.
$ tofu plan -var="env=prod" -var="vpc_version=5.13.0"
╷│ 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.
env = "prod"
$ TF_VAR_env=staging tofu init -reconfigure > /dev/null$ jq -r '.backend.config.key' .terraform/terraform.tfstate
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_ 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.
{"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.
$ tofu console> provider::aws::arn_parse("arn:aws:iam::444455556666:role/example")
{"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.
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 modulepolicy = data.aws_iam_policy_document.allow_deploy.jsonlifecycle {precondition {# the provider parses its own ARNs; no regex of yours to get wrongcondition = provider::aws::arn_parse(var.deploy_role_arn).account_id == data.aws_caller_identity.current.account_iderror_message = "deploy_role_arn is outside this account; refusing to grant it bucket access."}}}
$ tofu plan -var='deploy_role_arn=arn:aws:iam::444455556666:role/example'
╷│ 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.
# the token would be baked into .terraform/modules/modules.jsonmodule "vpc" {source = "git::https://oauth2:${var.git_token}@gitlab.example.com/acme/modules.git//vpc?ref=v1.20.4"}
$ tofu init
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".
variable "env" {type = stringconst = 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 = stringsensitive = trueconst = 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.
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?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.
$ git ls-files '*.tfvars' '*.tfvars.json' \| xargs -r grep -Hn '^[[:space:]]*env[[:space:]]*='
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.