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.
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:
- Any
-varand-var-fileoptions on the command line, in the order provided, and variables from HCP Terraform - Any
*.auto.tfvarsor*.auto.tfvars.jsonfiles, in lexical order - The
terraform.tfvars.jsonfile - The
terraform.tfvarsfile - Environment variables
- The
defaultargument 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.
| Source | Rank (1 = highest) | Lab step |
|---|---|---|
-var / -var-file (and HCP Terraform variables) | 1 | 1f |
*.auto.tfvars in lexical order | 2 | 1e |
terraform.tfvars.json | 3 | 1d |
terraform.tfvars | 4 | 1c |
TF_VAR_ environment variable | 5 | 1b |
default in the variable block | 6 | 1a |
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:
- Set each variable from one source per environment, and keep the default as a safe fallback only.
- Check every
TF_VAR_name against a variable block, because a typo there is silent. - Declare a variable in the root, then pass it into each module block as an argument.
- Expose module data through outputs, and mark any output carrying a secret with
sensitive = true. - Protect state storage and never print
terraform output <name>in CI logs.
| Exam cue | Answer |
|---|---|
| Lowest-ranked source for a variable | The default argument in the variable block |
| Two command-line options set one name | The one later on the command line wins |
a.auto.tfvars versus b.auto.tfvars | Lexical order, so b overrides a |
Misspelled TF_VAR_ name | Ignored without a warning |
Misspelled name in a .tfvars file or -var | Warning for the file, error for -var |
Root terraform.tfvars sets a child-only variable | Warning, and the child keeps its default |
| Parent reads a child's value | module.<LABEL>.<OUTPUT>, and only through an output |
| Sensitive output | Redacted 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
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?large, because terraform.tfvars ranks above environment variables, which rank above the default.terraform plan -var tier=gold -var-file=team.tfvars, and team.tfvars contains tier = "silver". Which value applies?silver, because command-line options apply in the order given and the later -var-file overrides the earlier -var.TF_VAR_enviroment=live, but the variable block is named environment. The plan succeeds and uses the default. Why?environment. A -var with the same typo would have failed with an error.main.tf contains value = module.app.local.name and validation fails with "Unsupported attribute". What is the correct fix?module.app.<OUTPUT>.sensitive = true. Is the secret safe from anyone who can read the state file or run terraform output -raw?-json or -raw, and in the lab even a plain terraform output dsn printed it.