Hedronite · Cert Lesson · Terraform Associate 004 · Objectives 4c and 5b · Mon 2026-10-26

Terraform Associate: variables and module scope where a variable's value comes from, and what a module can see

Values come in at the root, cross into a module only as arguments, and come back only as outputs.

Lesson Class: Cert (Terraform Associate 004 · 4c variables and outputs · 5b variable scope within modules · 5d note)
Tested with: Terraform 1.14.3 (exam targets 1.12) · local backend · no providers
Lab: lab/run.sh · out/cert-lab.txt
Source: Terraform Associate 004 content list · variables, values, output block and module block references · module usage guide
The Last Flag Wins
On the command line, -var and -var-file apply in the order given.
Typos Fail Differently
An undeclared TF_VAR_ is ignored, a tfvars entry warns, a -var errors.
Redacted Is Not Secret
A sensitive output still lives in state and prints with terraform output.
The variable was set in four places. Terraform never had to guess.

The problem

A team runs one Terraform configuration on laptops and in a continuous integration (CI) pipeline, the automated job that checks every change. The variable env has a default in the code, an environment variable set in the pipeline, a terraform.tfvars file in the repository, and a -var flag in one job. Nobody can say which of the four wins, so a plan on a laptop disagrees with the plan in CI.

Then a refactor moves naming logic into a module. The plan fails because the new module tries to read a variable that only the top-level configuration declared. Later, a database password shows up in a pipeline log because someone ran terraform output to debug. Both failures trace back to two rules: where values enter a configuration, and what a module is allowed to see.

Everything below was run with Terraform 1.14.3 in the command-line interface (CLI), with no providers and no cloud account. The Terraform Associate (004) exam targets Terraform 1.12. The behavior shown is long-standing, but the lab did not run 1.12 itself. Step numbers such as 1a or 2f refer to the lab script and transcript that accompany this lesson.

The idea, in plain words

Values enter a configuration only at the root module, the directory where you run Terraform commands, and they arrive from six kinds of sources ranked by a fixed order of precedence. Each module is a sealed scope. It sees only its own variables, its own local values (named expressions reused inside one module) and its own resources. It receives inputs as arguments in the module block that calls it, and it hands data back only through output blocks. Sensitivity travels with a value, so an output that carries a secret must say so explicitly, and even then the value stays readable in other places. Put shortly: values come in at the root, cross into a module only as arguments, and come back only as outputs.

Precedence: which source wins for a root variable

An input variable is a variable block that declares a named value a module accepts, optionally with a type and a default. A variable definitions file is a file ending in .tfvars (or .tfvars.json, the same idea written in JavaScript Object Notation, JSON) that assigns values to those names. The same name can be assigned from several places, so Terraform needs a fixed ranking. Otherwise the result would depend on who ran the command. The documentation gives this order, highest first:

  1. Any -var and -var-file options on the command line, in the order provided, and variables from HCP Terraform
  2. Any *.auto.tfvars or *.auto.tfvars.json files, in lexical order
  3. The terraform.tfvars.json file
  4. The terraform.tfvars file
  5. Environment variables
  6. The default argument of the variable block

The lab used this one-variable configuration:

variable "env" {
  type    = string
  default = "from-default"
}

The lab then added one source at a time and used terraform console to print the value of var.env after each addition. Each step kept every lower-ranked source in place, so any change in the output can be blamed on the newest source. The environment variable was set as TF_VAR_env, because the TF_VAR_ prefix followed by a variable name is how an environment variable targets a variable block.

### 1a default only
"from-default"

### 1b plus TF_VAR_env
"from-env-var"

### 1c plus terraform.tfvars
"from-terraform.tfvars"

### 1d plus terraform.tfvars.json
"from-terraform.tfvars.json"

### 1e plus a.auto.tfvars and b.auto.tfvars
"from-b.auto.tfvars"

Every step replaced the previous answer, which matches the ladder from the bottom up. In step 1e two files competed, and b.auto.tfvars overrode a.auto.tfvars because automatic definitions files load in lexical (alphabetical) order and the later file replaces the earlier one. Name auto files with that order in mind, otherwise a rename can quietly change which value wins.

Command-line options are the top rung, and among themselves they apply in the order given. The lab wrote env = "from-cli.tfvars" into cli.tfvars and swapped the argument order:

$ terraform console -var env=from-dash-var -var-file=cli.tfvars
"from-cli.tfvars"
$ terraform console -var-file=cli.tfvars -var env=from-dash-var
"from-dash-var"

Whichever option comes last on the command line wins. Within a single file there is no ranking at all, because assigning a name twice is rejected outright. The documentation says, "Once you assign a value to a variable, you cannot reassign that variable within the same file." The lab put env = "first" and env = "second" in one file and got:

Error: Attribute redefined

  on c.auto.tfvars line 2:
   2: env = "second"

The argument "env" was already set at c.auto.tfvars:1,1-4. Each argument may
be set only once.
SourceRank (1 = highest)Lab step
-var / -var-file (and HCP Terraform variables)11f
*.auto.tfvars in lexical order21e
terraform.tfvars.json31d
terraform.tfvars41c
TF_VAR_ environment variable51b
default in the variable block61a

Symptom: a CI job passes -var-file=live.tfvars -var env=staging and plans staging.

Cause: command-line options apply in the order given, so the later -var wins (step 1f).

Fix: pass one source per variable on the command line, or order the options deliberately so the intended value comes last.

Undeclared names get three different reactions

Terraform only accepts values for names that a variable block declares. What it does with an unknown name depends on how the value arrived. The documentation says Terraform "ignores any assigned environment variables that do not have a matching variable block," warns about an undeclared variable in a definitions file, and errors when -var assigns one. The lab misspelled region as regoin in all three ways against the one-variable configuration:

$ TF_VAR_regoin=us-east-1 terraform plan
No changes. Your infrastructure matches the configuration.
[exit 0]
$ terraform plan   (typo.auto.tfvars sets regoin)
No changes. Your infrastructure matches the configuration.
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
Warning: Value for undeclared variable
The root module does not declare a variable named "regoin" but a value was
found in file "typo.auto.tfvars". If you meant to use this value, add a
"variable" block to the configuration.
[exit 0]
$ terraform plan -var regoin=us-east-1
Error: Value for undeclared variable
A variable named "regoin" was assigned on the command line, but the root
module does not declare a variable of that name. To use this value, add a
"variable" block to the configuration.
[exit 1]

The environment variable produced no warning and exit code 0, the definitions file produced a warning and still exited 0, and -var stopped the run with exit code 1. The quiet case is the dangerous one. A misspelled TF_VAR_ name never reaches any variable, so the variable keeps whatever lower-ranked value it has, usually its default, and the pipeline stays green. The warning exists so that an accidental misspelling in a definitions file gets caught, while the error makes a command-line typo impossible to miss.

Symptom: a pipeline sets TF_VAR_regoin and the plan still uses the default region.

Cause: the name does not match any variable block, and Terraform ignores undeclared environment variables.

Fix: check the spelling against the variable blocks. Prefer a definitions file or -var where a typo should be loud.

Module scope: inputs in, outputs out

A child module is a module that another module calls. The caller does so with a module block, which names a source and then lists module arguments, the values handed to the child's input variables. The documentation puts it this way: "Variables define the input interface of your module by specifying the values your module accepts as arguments." Nothing else crosses the boundary inward, so a child cannot reach up and read a variable the root declared. Data leaves only through output values, which expose data from a module to its caller, and the caller reads one with module.<LABEL>.<OUTPUT>.

This is the working root and child from step 2b of the lab, with the child in modules/naming/main.tf:

# root main.tf
variable "env" {
  type    = string
  default = "staging"
}

module "naming" {
  source = "./modules/naming"
  env    = var.env
}

output "prefix" {
  value = module.naming.prefix
}

# modules/naming/main.tf
variable "env" {
  type = string
}

locals {
  prefix = "notes-${var.env}"
}

output "prefix" {
  value = local.prefix
}

The root owns the value and declares env with a default. The module block passes it down with env = var.env, and the child declares its own env variable to receive it. The child computes a local value, prefix, and exposes it through an output of the same name, which the root re-exports as module.naming.prefix. Each hop is explicit, so a reader can trace any value by following variable, argument, local and output in turn.

Before the child declared env, validation failed:

Error: Reference to undeclared input variable
  on modules/naming/main.tf line 2, in locals:
   2:   prefix = "notes-${var.env}"
An input variable with the name "env" has not been declared. This variable
can be declared with a variable "env" {} block.

Once the variable was declared and passed, the plan showed Changes to Outputs: with + prefix = "notes-staging". The reverse direction is also sealed. The lab added a root output reading module.naming.local.prefix, and Terraform rejected it:

Error: Unsupported attribute
  on main.tf line 16, in output "child_local":
  16:   value = module.naming.local.prefix
    ├────────────────
    │ module.naming is a object
This object does not have an attribute named "local".

Locals are private to their module, so only an output block makes a value visible. The last test gave the child an owner variable with the default "platform-team" and put owner = "payments-team" in a root terraform.tfvars. The plan still showed + owner = "platform-team", with this warning:

Warning: Value for undeclared variable
The root module does not declare a variable named "owner" but a value was
found in file "terraform.tfvars". If you meant to use this value, add a
"variable" block to the configuration.

Definitions files and TF_VAR_ variables feed only the root module. The documentation states that "Child modules receive their inputs from a parent module as arguments." The child's owner is a different scope from the root's names, so the file never reached it.

Symptom: after moving resources into a module, terraform.tfvars values are ignored with a warning.

Cause: a definitions file sets root variables only, and the child's variable lives in a separate scope.

Fix: declare the variable at the root and pass it in the module block.

Outputs, sensitivity and state

An output block must include a value argument. State is the data file in which Terraform records what it manages, and output values are stored there too. The sensitive argument hides a value in CLI output, but the documentation warns that "Terraform also records sensitive values in state, so anyone who can access your state data can access your sensitive values." The lab's child gained a sensitive variable and a sensitive output:

variable "db_password" {
  type      = string
  sensitive = true
  default   = "lab-only-not-a-secret"
}

output "dsn" {
  value     = "postgres://app:${var.db_password}@db.internal:5432/notes"
  sensitive = true
}

The password variable is marked sensitive and has a throwaway default so the lab needs no input. The dsn output (a database connection string) interpolates that password, so the derived string is sensitive too, and the child marks its output sensitive = true to match. The question is what happens one level up, at the root.

The root first re-exported it with value = module.naming.dsn and no flag. Terraform refused, so that a sensitive value cannot become a visible root output by accident:

Error: Output refers to sensitive values
  on main.tf line 20:
  20: output "dsn" {
If you do intend to export this data, annotate the output value as sensitive
by adding the following argument:
    sensitive = true

With sensitive = true added to the root output, the lab applied to local state and read the value back. The apply printed dsn = <sensitive>. Then:

$ terraform output dsn
"postgres://app:[email protected]:5432/notes"
$ terraform output -raw dsn
postgres://app:[email protected]:5432/notes
state file mentions the password: 1 line(s)

The documentation names -json and -raw as the flags that display sensitive values in plain text. In this lab, asking for the single named output without either flag also printed the plaintext in Terraform 1.14.3, so any terraform output <name> in a log should be treated as a leak. The documented apply display is (sensitive value) while 1.14.3 printed <sensitive>. An exam answer should say the value is redacted, not quote a literal string.

On objective 5d, the version argument applies only to registry modules. The documentation says, "Modules sourced from local file paths do not support version because they're loaded from the same source repository and always share the same version as their caller." The lab added version = "1.0.0" to the local module and terraform init failed:

Error: Invalid registry module source address
  on main.tf line 7, in module "naming":
   7:   source  = "./modules/naming"
Failed to parse module registry address: can't use local directory
"./modules/naming" as a module registry address.
Terraform assumed that you intended a module registry source address because
you also set the argument "version", which applies only to registry modules.

Symptom: terraform plan fails with "Output refers to sensitive values" after a module upgrade.

Cause: a child output became sensitive, and the root re-exports it without the flag.

Fix: add sensitive = true to the root output, and accept that the value is still in state and readable with terraform output.

Putting it together

For the team in the opening scenario, a short checklist removes most of the confusion:

Exam cueAnswer
Lowest-ranked source for a variableThe default argument in the variable block
Two command-line options set one nameThe one later on the command line wins
a.auto.tfvars versus b.auto.tfvarsLexical order, so b overrides a
Misspelled TF_VAR_ nameIgnored without a warning
Misspelled name in a .tfvars file or -varWarning for the file, error for -var
Root terraform.tfvars sets a child-only variableWarning, and the child keeps its default
Parent reads a child's valuemodule.<LABEL>.<OUTPUT>, and only through an output
Sensitive outputRedacted in plan and apply, still stored in state

The lab verified the rows above on Terraform 1.14.3. It did not test HCP Terraform variables, which the documentation ranks alongside the command line, nor registry module version constraints, nor ephemeral values, nor Terraform 1.12 itself.

Check yourself

Question 1
A variable size has a default of small, TF_VAR_size=medium in the environment, and size = "large" in terraform.tfvars. No other sources exist. Which value does the root module use?
tap to reveal
It uses large, because terraform.tfvars ranks above environment variables, which rank above the default.
Question 2
A job runs terraform plan -var tier=gold -var-file=team.tfvars, and team.tfvars contains tier = "silver". Which value applies?
tap to reveal
It uses silver, because command-line options apply in the order given and the later -var-file overrides the earlier -var.
Question 3
A pipeline exports TF_VAR_enviroment=live, but the variable block is named environment. The plan succeeds and uses the default. Why?
tap to reveal
Terraform ignores environment variables with no matching variable block, so the misspelled name never reached environment. A -var with the same typo would have failed with an error.
Question 4
A root main.tf contains value = module.app.local.name and validation fails with "Unsupported attribute". What is the correct fix?
tap to reveal
Locals are private to the child, so the child must expose the value in an output block, and the root should read it as module.app.<OUTPUT>.
Question 5
A root output re-exports a sensitive child output with sensitive = true. Is the secret safe from anyone who can read the state file or run terraform output -raw?
tap to reveal
No. The flag only redacts plan and apply output; Terraform still records the value in state and prints it with -json or -raw, and in the lab even a plain terraform output dsn printed it.

Sources