Input variables

A configuration with hard-coded values serves one environment. To use the same files for development, test and production, Ravi Kumar turns the values that differ into parameters. An input variable is such a parameter: declared in a variable block, given a value from outside when you run OpenTofu, and read inside the configuration as var.<name>. It works like a function parameter, with the whole configuration as the function. The variables.tf of Ravi's second configuration, in /home/amara/kestrel-infra/vars:

variable "environment" {
  description = "Name of the environment."
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "The environment must be one of: dev, test, prod."
  }
}

variable "web_count" {
  description = "Number of portal web servers."
  type        = number
  default     = 2

  validation {
    condition     = var.web_count >= 1 && var.web_count <= 6
    error_message = "The web_count must be between 1 and 6."
  }
}

variable "services" {
  description = "Kestrel services and their ports."
  type        = map(number)
  default = {
    portal    = 8080
    meter-api = 8443
    billing   = 9100
  }
}

variable "db_admin_password" {
  description = "Initial password for the PostgreSQL admin on db01."
  type        = string
  default     = "example-only"
  sensitive   = true
}

The arguments a variable block can have:

  • description: what the variable is for. It appears when OpenTofu prompts for a value and in generated documentation; write one for every variable.
  • type: which values are allowed (next section).
  • default: the value when none is given. A variable without a default is required.
  • validation: extra rules, with an error message.
  • sensitive = true: hide the value in plan and apply output. It is still stored in the state; see State: How Terraform Remembers.
  • nullable = false: refuse null as a value. Newer versions also have ephemeral = true (OpenTofu 1.11, Terraform 1.10) for values that must never be written to state or plan files.

Types

A type constraint, the type argument, tells OpenTofu what shape of value the variable accepts, so a wrong value is rejected with a clear error before anything is planned. The building blocks:

Type Example value Notes
string "prod"
number 2, 0.5 Whole and fractional numbers
bool true
list(string) ["192.0.2.11", "192.0.2.12"] Ordered; duplicates allowed
set(string) ["web01", "web02"] No order, no duplicates
map(number) { portal = 8080 } Keys are strings; all values one type
object({ … }) { name = "db01", port = 5432 } Named attributes, each with its own type; optional() marks attributes that may be left out
tuple([…]) ["db01", 5432] Fixed length, a type per position
any No check; avoid it in shared code

An object type describes a structured setting in one variable:

variable "database" {
  description = "Connection settings for the portal database."
  type = object({
    host = string
    port = optional(number, 5432)
    name = string
  })
  default = {
    host = "192.0.2.31"
    name = "portal"
  }
}

Here var.database.port is 5432 because the attribute is optional with that default. A value of the wrong type fails at plan time. With web_servers = "192.0.2.11" (a string instead of a list) in terraform.tfvars of the portal configuration:

$ tofu plan
…
╷
│ Error: Invalid value for input variable
│
│   on variables.tf line 7:
│    7: variable "web_servers" {
│
│ The given value is not suitable for var.web_servers declared at
│ variables.tf:7,1-23: list of string required, but have string.
╵

The error points at the declaration, not at the .tfvars file, because the declaration holds the rule. And tofu validate did not catch it: validate checks the configuration files but does not read .tfvars files.

Validation rules

A type cannot say "only dev, test or prod" or "between 1 and 6". A variable validation rule can: a validation block with a condition that must be true and an error_message for when it is not. A typo in the environment name is stopped before any resource is touched:

$ tofu plan -var environment=staging

Changes to Outputs:
  + db_admin_password = (sensitive value)

You can apply this plan to save these new output values to the OpenTofu
state, without changing any real infrastructure.
╷
│ Error: Invalid value for variable
│
│   on variables.tf line 1:
│    1: variable "environment" {
│     ├────────────────
│     │ var.environment is "staging"
│
│ The environment must be one of: dev, test, prod.
│
│ This was checked by the validation rule at variables.tf:6,3-13.
╵

The command exited with status 1. The lines before the error are a side effect of how OpenTofu works: it checks each rule as soon as the value is needed, and had already planned the parts that did not depend on environment. The same happened with -var web_count=10, where the output first listed four planned resources under "OpenTofu planned the following actions, but then encountered a problem" before the error. Look for the error box; nothing was changed. OpenTofu 1.9 and Terraform 1.9 and later also allow a condition to refer to other variables, such as "the production environment needs at least two web servers".

Giving variables a value

There are several ways to set a variable, and when more than one is used, OpenTofu and Terraform apply them in a fixed order in which the later source wins:

Variable precedence, from lowest at the bottom to highest at the top. Default in the variable block. Environment variables named TF_VAR_ followed by the variable name. The file terraform.tfvars, then terraform.tfvars.json. Files ending in .auto.tfvars or .auto.tfvars.json, in alphabetical order. The -var and -var-file options on the command line, in the order given. A higher source overrides a lower one. -var and -var-file (in command-line order) *.auto.tfvars, *.auto.tfvars.json (alphabetical) terraform.tfvars, then terraform.tfvars.json TF_VAR_name environment variables default in the variable block wins lowest no value anywhere and no default: OpenTofu asks, or fails
Each source overrides the ones below it, variable by variable.
$ export TF_VAR_web_count=3
$ tofu plan -var environment=test
$ tofu plan -var-file=prod.tfvars

Two details catch people out. First, a map or object value is replaced as a whole, not merged: setting services in a .tfvars file with only portal in it drops meter-api and billing. Second, the order is per variable, as this small model shows:

# Where does each variable get its value? Sources from lowest to highest
# precedence, as OpenTofu and Terraform apply them: later sources win.
sources = [
    ("default in variables.tf", {"environment": "dev", "web_count": 2}),
    ("TF_VAR_web_count=3", {"web_count": 3}),
    ("terraform.tfvars", {"environment": "test", "web_count": 4}),
    ("prod.auto.tfvars", {"environment": "prod"}),
    ("-var web_count=6", {"web_count": 6}),
]

final = {}
for source, values in sources:
    for name, value in values.items():
        final[name] = (value, source)

for name, (value, source) in sorted(final.items()):
    print(f"{name} = {value!r:8} from {source}")
environment = 'prod'   from prod.auto.tfvars
web_count = 6        from -var web_count=6

When a required variable has no value from any source, OpenTofu asks for it, showing the description — handy at a terminal, useless in a pipeline. With -input=false, which pipelines should always use, it fails instead:

$ tofu plan -input=false
╷
│ Error: No value for required variable
│
│   on variables.tf line 1:
│    1: variable "environment" {
│
│ The root module input variable "environment" is not set, and has no default
│ value. Use a -var or -var-file command line argument to provide a value for
│ this variable.
╵

How to organize .tfvars files for dev, test and production is the subject of Environments: Dev, Test and Production.

Local values

A local value gives a name to an expression computed inside the configuration, so it is written once and used in many places. Locals are declared in a locals block and read as local.<name> (singular). Unlike variables, they cannot be set from outside. The first part of Ravi's main.tf:

locals {
  prefix = "kestrel-${var.environment}"
  common_tags = {
    owner       = "platform-team"
    environment = var.environment
  }
}

Every name in the configuration now starts with local.prefix, so a change of naming convention is one edit. A good rule: a value that people choose is a variable; a value the configuration derives from them is a local.

count and for_each

Some arguments work on any resource type, because OpenTofu itself handles them rather than the provider. Such an argument is a meta-argument: count, for_each, depends_on, provider and lifecycle. The first two create several instances from one block. The rest of main.tf:

# count: numbered copies
resource "local_file" "web" {
  count    = var.web_count
  filename = "${path.module}/out/${local.prefix}-web${format("%02d", count.index + 1)}.txt"
  content  = "host=web${format("%02d", count.index + 1)} ip=${cidrhost("192.0.2.0/24", 11 + count.index)}\n"
}

# for_each: one file per named service
resource "local_file" "service" {
  for_each = var.services
  filename = "${path.module}/out/${local.prefix}-${each.key}.conf"
  content  = "service=${each.key}\nport=${each.value}\n"
}

resource "local_sensitive_file" "db_admin" {
  filename = "${path.module}/out/${local.prefix}-db01.secret"
  content  = "POSTGRES_PASSWORD=${var.db_admin_password}\n"
}
  • count = 2 creates instances local_file.web[0] and [1]; inside the block, count.index is the number of the current copy, starting at 0.
  • for_each takes a map or a set of strings and creates one instance per element, addressed by key: local_file.service["portal"]. Inside the block, each.key is the key and each.value the value.
$ tofu apply -auto-approve
$ tofu state list
…
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:

db_admin_password = <sensitive>
service_ports = {
  "billing" = 9100
  "meter-api" = 8443
  "portal" = 8080
}
web_files = [
  "./out/kestrel-dev-web01.txt",
  "./out/kestrel-dev-web02.txt",
]
local_file.service["billing"]
local_file.service["meter-api"]
local_file.service["portal"]
local_file.web[0]
local_file.web[1]
local_sensitive_file.db_admin

Why for_each is usually the better choice

The difference matters the day an item disappears. A test configuration manages three web server files twice: once with count over a list, once with for_each over the same names as a set.

variable "web_hosts" {
  type    = list(string)
  default = ["web01", "web02", "web03"]
}

# Version A: count over a list
resource "local_file" "by_index" {
  count    = length(var.web_hosts)
  filename = "${path.module}/out/count-${var.web_hosts[count.index]}.txt"
  content  = "${var.web_hosts[count.index]}\n"
}

# Version B: for_each over a set
resource "local_file" "by_name" {
  for_each = toset(var.web_hosts)
  filename = "${path.module}/out/each-${each.key}.txt"
  content  = "${each.key}\n"
}

web01 is retired. Remove it from the list and look at the plan (the attribute lines are left out):

$ tofu plan -var 'web_hosts=["web02","web03"]'
…
  # local_file.by_index[0] must be replaced
-/+ resource "local_file" "by_index" {
      ~ content              = <<-EOT # forces replacement
          - web01
          + web02
        EOT
…
      ~ filename             = "./out/count-web01.txt" -> "./out/count-web02.txt" # forces replacement
…
  # local_file.by_index[1] must be replaced
-/+ resource "local_file" "by_index" {
      ~ content              = <<-EOT # forces replacement
          - web02
          + web03
        EOT
…
  # local_file.by_index[2] will be destroyed
  # (because index [2] is out of range for count)
…
  # local_file.by_name["web01"] will be destroyed
  # (because key ["web01"] is not in for_each map)
…
Plan: 2 to add, 0 to change, 4 to destroy.

With count, every element after the removed one moved up one index: [0] was web01 and must now be web02, so it is replaced; [1] becomes web03, also replaced; and [2] is out of range and destroyed. Three actions for one removal. With for_each, only ["web01"] is destroyed and the others are untouched. For files this is harmless; for virtual machines, "replaced" means web02 and web03 are deleted and rebuilt.

That stable key is why for_each is the right meta-argument for anything that has a name: each instance is tied to its key, not to its position. Keep count for truly interchangeable copies, and for switching a resource on or off:

resource "local_file" "billing_export" {
  count    = var.environment == "prod" ? 1 : 0
  filename = "${path.module}/out/billing-export.cron"
  content  = "30 2 * * * billing /opt/kestrel/bin/export-billing\n"
}

OpenTofu 1.11 added a clearer way to write the second case, which Terraform does not have: enabled inside a lifecycle block. It cannot be combined with count or for_each, and the resource keeps a plain address without [0].

resource "local_file" "billing_export" {
  filename = "${path.module}/out/billing-export.cron"
  content  = "30 2 * * * billing /opt/kestrel/bin/export-billing\n"

  lifecycle {
    enabled = var.environment == "prod"
  }
}

Outputs

An output value is the configuration's return value: something OpenTofu prints after apply, stores in the state, and makes available to tofu output, to scripts and to other configurations. Typical outputs are generated names, addresses and IDs that someone needs next — the address of the new database server, the URL of the portal. Ravi's outputs.tf:

output "web_files" {
  value = local_file.web[*].filename
}

output "service_ports" {
  value = { for name, f in local_file.service : name => var.services[name] }
}

output "db_admin_password" {
  value     = var.db_admin_password
  sensitive = true
}

An output that uses a sensitive value must itself be marked sensitive = true; OpenTofu refuses otherwise, so that a secret cannot leak through an output by accident. Scripts should read outputs as JSON (tofu output -json) or one value without quotes (tofu output -raw name) rather than parse the text. Inside a module, outputs are how the module hands values to its caller — see Modules and Reuse.

Data sources

Not everything a configuration needs is created by it. The network was built by another team, the base image is published by the operating system vendor, the DNS zone already exists. A data source reads information about such an existing object without managing it. It is declared with a data block and read as data.<type>.<name>. A local example that reads a file maintained by hand:

data "local_file" "maintenance_page" {
  filename = "${path.module}/files/maintenance.html"
}

resource "local_file" "maintenance_copy" {
  filename = "${path.module}/out/maintenance.html"
  content  = data.local_file.maintenance_page.content
}

In the cloud the pattern is the same. This illustrative AWS snippet looks up the production network by its tag and places a new subnet in it, computing the subnet's address range from the network's:

data "aws_vpc" "main" {
  tags = {
    Name = "kestrel-prod"
  }
}

resource "aws_subnet" "portal" {
  vpc_id     = data.aws_vpc.main.id
  cidr_block = cidrsubnet(data.aws_vpc.main.cidr_block, 8, 3)
}

Data sources are read during plan, so the plan works with current information; when a data source depends on something not yet created, it is read during apply instead and the plan shows it with the <= symbol. A data source that finds nothing, or more than one match where one is expected, is usually an error — which is what you want: better a failed plan than a subnet in the wrong network.

Expressions

Anywhere a value is expected you can write an expression. The ones you will use most:

Expression Example Result
References var.environment, local.prefix, random_id.deployment.hex A value from elsewhere in the configuration
String template "kestrel-${var.environment}" "kestrel-dev"
Arithmetic and comparison var.web_count >= 1 && var.web_count <= 6 true
Conditional var.environment == "prod" ? 3 : 1 1 in dev
for expression (list) [for h in ["web01", "web02"] : "${h}.kestrel.example"] A list of two names
for expression (map, filtered) { for name, port in var.services : name => port if port > 9000 } { billing = 9100 }
Splat local_file.web[*].filename The attribute of every instance, as a list

for expressions are the comprehensions of HCL. For repeating a nested block inside a resource, such as one firewall rule block per port, there is a dynamic block; use it sparingly, because it makes plans harder to read.

Built-in functions and tofu console

Outside the experimental symbol libraries of OpenTofu 1.13, HCL has no way to define your own functions, but it ships with many built-in ones: string functions (format, join, split, upper), collection functions (length, keys, merge, lookup, toset), network functions (cidrhost, cidrsubnet), file functions (file, templatefile), encoding functions (jsonencode, yamlencode, base64encode) and error-handling helpers (try, coalesce). Providers can add functions of their own, called as provider::<name>::<function>() (OpenTofu 1.7, Terraform 1.8). OpenTofu 1.13 added convert() for converting a value to a given type.

The quickest way to learn them is tofu console (or terraform console): an interactive prompt that evaluates expressions against the current configuration and state, without changing anything. In the vars folder after the apply above:

$ tofu console
…
> local.prefix
"kestrel-dev"
> var.services
tomap({
  "billing" = 9100
  "meter-api" = 8443
  "portal" = 8080
})
> var.db_admin_password
(sensitive value)
> cidrsubnet("10.20.0.0/16", 8, 3)
"10.20.3.0/24"
> [for i in range(3) : cidrsubnet("10.20.0.0/16", 8, i)]
[
  "10.20.0.0/24",
  "10.20.1.0/24",
  "10.20.2.0/24",
]
> cidrhost("192.0.2.0/24", 11)
"192.0.2.11"
> format("web%02d.kestrel.example", 7)
"web07.kestrel.example"
…
> lookup(var.services, "ledger", 0)
0
> merge(local.common_tags, { service = "meter-api" })
{
  "environment" = "dev"
  "owner" = "platform-team"
  "service" = "meter-api"
}
> { for name, port in var.services : name => "${name}:${port}" if port > 9000 }
{
  "billing" = "billing:9100"
}
…
> toset(["web01", "web02", "web01"])
toset([
  "web01",
  "web02",
])
> local_file.service["portal"].filename
"./out/kestrel-dev-portal.conf"
…
> try(var.services["ledger"], 9999)
9999
> var.web_count + "two"
╷
│ Error: Invalid operand
│
│   on <console-input> line 1:
│   (source code not available)
│
│ Unsuitable value for right operand: a number is required.
╵

The console shows types too: tomap(…), tolist(…) and toset(…) (note that the set dropped the duplicate). Sensitive values stay hidden. Type exit or press Ctrl+D to leave.

cidrsubnet(prefix, newbits, netnum) deserves a closer look, because subnet plans are built with it: it lengthens the prefix by newbits bits and returns subnet number netnum. A /16 plus 8 bits gives /24 subnets, and number 3 is 10.20.3.0/24. The same arithmetic with Python's ipaddress module (see prefix notation):

import ipaddress


def cidrsubnet(prefix, newbits, netnum):
    """Like OpenTofu's cidrsubnet(): subnet number netnum, newbits longer."""
    network = ipaddress.ip_network(prefix)
    subnets = network.subnets(prefixlen_diff=newbits)
    for number, subnet in enumerate(subnets):
        if number == netnum:
            return str(subnet)


def cidrhost(prefix, hostnum):
    """Like OpenTofu's cidrhost(): address number hostnum in the prefix."""
    return str(ipaddress.ip_network(prefix).network_address + hostnum)


print(cidrsubnet("10.20.0.0/16", 8, 3))
print([cidrsubnet("10.20.0.0/16", 8, i) for i in range(3)])
print([cidrhost("192.0.2.0/24", 11 + i) for i in range(2)])
10.20.3.0/24
['10.20.0.0/24', '10.20.1.0/24', '10.20.2.0/24']
['192.0.2.11', '192.0.2.12']

The last line matches the files that count created: web01 got 192.0.2.11 and web02 got 192.0.2.12.

Errors you will meet

tofu validate catches most mistakes in expressions before a plan is even attempted, and its messages are precise: file, line, the offending code and often a suggestion. All four of these were recorded on the portal configuration, each with exit status 1.

A misspelled argument name:

╷
│ Error: Unsupported argument
│
│   on main.tf line 17, in resource "local_file" "upstream":
│   17:   file_permisson = "0644"
│
│ An argument named "file_permisson" is not expected here. Did you mean
│ "file_permission"?
╵

A reference to a variable that was never declared:

╷
│ Error: Reference to undeclared input variable
│
│   on main.tf line 13, in resource "local_file" "upstream":
│   13:     environment = var.enviroment
│
│ An input variable with the name "enviroment" has not been declared. Did you
│ mean "environment"?
╵

An attribute the resource does not have:

╷
│ Error: Unsupported attribute
│
│   on main.tf line 15, in resource "local_file" "upstream":
│   15:     deployment  = random_id.deployment.hexadecimal
│
│ This object has no argument, nested block, or exported attribute named
│ "hexadecimal".
╵

A value of the wrong type for a resource argument:

╷
│ Error: Incorrect attribute value type
│
│   on main.tf line 2, in resource "random_id" "deployment":
│    2:   byte_length = "four"
│
│ Inappropriate value for attribute "byte_length": a number is required.
╵

For an unknown attribute, the provider's documentation lists every attribute a resource exports; tofu state show on an existing resource shows them with their values. Troubleshooting Infrastructure as Code covers errors that come from providers and platforms rather than from the language.