Phase 4 · ORCHESTRATION & IAC
Terraform 2 — variables, outputs, state
By the end of today
- Declare typed input variables with defaults and override them via tfvars and -var
- Add an output and read one value with terraform output -raw
- Explain what terraform.tfstate is and why you never hand-edit or commit it
Variables, outputs, and the state file that ties config to reality
Yesterday you ran your first terraform apply with the filename and text hard-coded into main.tf. That works once, but hard-coded config is a template you can’t reuse — change one value and you edit the resource block by hand every time. Today you make that config parameterised, inspectable, and honest about what it built using three features: input variables, output values, and state.
Input variables are the knobs. A variable "filename" {} block declares an input with a type (string, number, bool, list, map, object) and an optional default; inside the config you read it as var.filename. You set a variable three ways, and the order matters: a default in the block (lowest), a terraform.tfvars file that Terraform auto-loads, and a -var flag on the command line (highest — it overrides both). That precedence is why the same config can produce a dev file and a prod file with no code change.
Output values are the readout. An output "file_path" {} block surfaces a value — usually a computed attribute of a resource, like the id or the path Terraform actually created — after apply. Outputs hand a value to a human (the apply summary), to a script (terraform output -raw), or to another module. They turn Terraform from a black box into something that tells you what it did.
State is the memory. When Terraform creates a resource it records the mapping between your config and the real object in a JSON file, terraform.tfstate. That file is the source of truth: on the next apply Terraform reads state, compares it to your config and to reality, and plans only the difference. Delete state and Terraform forgets it owns anything — it will try to create duplicates.
Real world: State is a hotel’s front-desk register. The rooms (real resources) exist whether or not the book is accurate — but the register is how the desk knows room 204 belongs to the Terraform guest, so it doesn’t hand it out twice or bill the wrong person. Lose the register and you’re walking every floor guessing which rooms you manage.
Two rules follow. Never hand-edit terraform.tfstate — one mistyped field corrupts Terraform’s picture of reality; use the terraform state subcommands instead. And state holds secrets in plain text — a database password in your config lands verbatim in the state JSON — so terraform.tfstate goes in .gitignore, never in a commit.
For a team a local state file breaks down fast: two engineers with two copies both think they own the infra, and their applies clobber each other. The fix is remote state — one shared copy with locking so only one apply runs at a time. HCP Terraform (HashiCorp’s managed service, formerly Terraform Cloud) does exactly this: it stores state remotely, locks it during a run, and keeps a version history you can roll back; a self-hosted cloud object-store backend is the same idea. You stay local today — just know the local file is a solo tool.
Hands-On Lab
Budget about 25 minutes in your WSL2 Ubuntu 24.04 terminal with Terraform 1.10 or newer installed (Day 76’s setup). This is entirely local — the local provider writes a file on your own disk, so it costs ₹0 and touches no cloud. Make a fresh directory and move into it: mkdir ~/tf-vars && cd ~/tf-vars. Paths below show /home/you/... — yours reflect your own username and working directory. The local_file id is the SHA1 checksum of the file content, so identical content always produces the same id you see here.
Create main.tf — the same local_file from Day 76, but every value now comes from a variable, plus one output:
terraform {
required_version = ">= 1.10"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "greeting" {
filename = var.filename
content = var.content
}
output "file_path" {
description = "Absolute path of the file Terraform manages"
value = abspath(local_file.greeting.filename)
}
Create variables.tf — two typed inputs, each with a default so the config runs with no arguments:
variable "filename" {
description = "Path of the file to create, relative to this directory"
type = string
default = "hello.txt"
}
variable "content" {
description = "Text written into the file"
type = string
default = "Managed by Terraform."
}
# 1. Download the local provider and set up this directory (needed once, or when providers change).
terraform init
# Output (your minor and patch versions will differ — anything satisfying the ~> 2.5 constraint works):
# Initializing the backend...
# Initializing provider plugins...
# - Finding hashicorp/local versions matching "~> 2.5"...
# - Installing hashicorp/local v2.9.1...
# - Installed hashicorp/local v2.9.1 (signed by HashiCorp)
#
# Terraform has created a lock file .terraform.lock.hcl to record the provider
# selections it made above.
#
# Terraform has been successfully initialized!
# 2. Apply with NO variables passed, so both fall back to the defaults in variables.tf.
terraform apply -auto-approve
# Output (trimmed):
# Terraform will perform the following actions:
#
# # local_file.greeting will be created
# + resource "local_file" "greeting" {
# + content = "Managed by Terraform."
# + content_base64sha256 = (known after apply)
# + content_base64sha512 = (known after apply)
# + content_md5 = (known after apply)
# + content_sha1 = (known after apply)
# + content_sha256 = (known after apply)
# + content_sha512 = (known after apply)
# + directory_permission = "0777"
# + file_permission = "0777"
# + filename = "hello.txt"
# + id = (known after apply)
# }
#
# Plan: 1 to add, 0 to change, 0 to destroy.
#
# Changes to Outputs:
# + file_path = "/home/you/tf-vars/hello.txt"
#
# local_file.greeting: Creating...
# local_file.greeting: Creation complete after 0s [id=bff4c8b015c3e93b3e4d822aef18aa6e659578e9]
#
# Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
#
# Outputs:
#
# file_path = "/home/you/tf-vars/hello.txt"
Create terraform.tfvars — Terraform auto-loads this file, so its values override the defaults with no flag:
filename = "greeting.txt"
content = "Written from terraform.tfvars."
# 3. Apply again. terraform.tfvars is auto-loaded, so its values now win over the defaults.
terraform apply -auto-approve
# Output (trimmed; changing an attribute that forces replacement recreates the file):
# # local_file.greeting must be replaced
# -/+ resource "local_file" "greeting" {
# ~ content = "Managed by Terraform." -> "Written from terraform.tfvars." # forces replacement
# ~ content_base64sha256 = "…" -> (known after apply)
# ~ content_base64sha512 = "…" -> (known after apply)
# ~ content_md5 = "…" -> (known after apply)
# ~ content_sha1 = "…" -> (known after apply)
# ~ content_sha256 = "…" -> (known after apply)
# ~ content_sha512 = "…" -> (known after apply)
# ~ filename = "hello.txt" -> "greeting.txt" # forces replacement
# ~ id = "bff4c8b015c3e93b3e4d822aef18aa6e659578e9" -> (known after apply)
# # (2 unchanged attributes hidden)
# }
#
# Plan: 1 to add, 0 to change, 1 to destroy.
#
# Changes to Outputs:
# ~ file_path = "/home/you/tf-vars/hello.txt" -> "/home/you/tf-vars/greeting.txt"
#
# local_file.greeting: Destroying... [id=bff4c8b015c3e93b3e4d822aef18aa6e659578e9]
# local_file.greeting: Destruction complete after 0s
# local_file.greeting: Creating...
# local_file.greeting: Creation complete after 0s [id=00ca1d479a912c3cadd5b1564175482bbe831071]
#
# Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
#
# Outputs:
#
# file_path = "/home/you/tf-vars/greeting.txt"
# 4. Now pass -var on the CLI. A -var flag OUTRANKS terraform.tfvars, so this content wins.
terraform apply -auto-approve -var="content=Overridden on the CLI."
# Output (trimmed — same filename, but the new content still forces a replace):
# # local_file.greeting must be replaced
# -/+ resource "local_file" "greeting" {
# ~ content = "Written from terraform.tfvars." -> "Overridden on the CLI." # forces replacement
# …
# }
#
# Plan: 1 to add, 0 to change, 1 to destroy.
#
# local_file.greeting: Destroying... [id=00ca1d479a912c3cadd5b1564175482bbe831071]
# local_file.greeting: Destruction complete after 0s
# local_file.greeting: Creating...
# local_file.greeting: Creation complete after 0s [id=5436b6770584677939eb51c0fe46201e1dfa2254]
#
# Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
#
# Outputs:
#
# file_path = "/home/you/tf-vars/greeting.txt"
# 5. Read the outputs. Bare `terraform output` lists them; -raw prints ONE value with no quotes.
terraform output
# Output:
# file_path = "/home/you/tf-vars/greeting.txt"
terraform output -raw file_path
# Output (no quotes, no trailing newline — pipeable straight into another command):
# /home/you/tf-vars/greeting.txt
# 6. List every resource Terraform is tracking in state — one address per line.
terraform state list
# Output:
# local_file.greeting
# 7. Show the full recorded state of one resource — this is Terraform's memory of the real file.
terraform state show local_file.greeting
# Output:
# # local_file.greeting:
# resource "local_file" "greeting" {
# content = "Overridden on the CLI."
# content_base64sha256 = "l6H8c5HH2WBgaOHSPlJ4/j2J86fSKBZtXUOGbJXRLyQ="
# content_base64sha512 = "UTAKZj4on3II7i7tVWd+pZkpHF8aPbU1JpZ3gHh9umeJNDD+kF7JB/cnN/yYhMzELTNofKl9huamMvJIG1Zykw=="
# content_md5 = "98256d7a5725e4ca377fdb81182c25f3"
# content_sha1 = "5436b6770584677939eb51c0fe46201e1dfa2254"
# content_sha256 = "97a1fc7391c7d9606068e1d23e5278fe3d89f3a7d228166d5d43866c95d12f24"
# content_sha512 = "51300a663e289f7208ee2eed55677ea599291c5f1a3db53526967780787dba67893430fe905ec907f72737fc9884ccc42d33687ca97d86e6a632f2481b567293"
# directory_permission = "0777"
# file_permission = "0777"
# filename = "greeting.txt"
# id = "5436b6770584677939eb51c0fe46201e1dfa2254"
# }
# 8. Tear it down. Destroy removes the real file AND the resource's entry in state.
terraform destroy -auto-approve
# Output (trimmed):
# # local_file.greeting will be destroyed
# - resource "local_file" "greeting" {
# - content = "Overridden on the CLI." -> null
# - filename = "greeting.txt" -> null
# …
# }
#
# Plan: 0 to add, 0 to change, 1 to destroy.
#
# local_file.greeting: Destroying... [id=5436b6770584677939eb51c0fe46201e1dfa2254]
# local_file.greeting: Destruction complete after 0s
#
# Destroy complete! Resources: 1 destroyed.
Read the arc back: one config, three different files, driven only by where the value came from — default, then tfvars, then -var. State remembered exactly one live resource the whole time, and destroy cleared both the file and its state entry.
Common Errors & Fixes
These three catch almost everyone in their first week with variables and state. Read the error text slowly — parsing it is the real skill.
Common error: A
variableblock with nodefault, and no value supplied on the command line:Error: No value for required variable on variables.tf line 1: 1: variable "filename" { The root module input variable "filename" is not set, and has no default value. Use a -var or -var-file command line argument to provide a value for this variable.Why: A variable without a
defaultis required. In an interactive shell Terraform doesn’t fail here — it silently stops and promptsvar.filename\n Enter a value:, which in CI or a script looks like a frozen, hung run. With-input=false(how automation runs) it fails immediately with the error above.Fix: Give the variable a sensible
default, or supply the value at run time with-var="filename=hello.txt"or a-var-file. In pipelines, setTF_VAR_filenameas an environment variable so nothing waits on a prompt.How you’d spot it in prod: A CI
terraform applythat hangs until it times out, or fails with “No value for required variable,” almost always means a variable lost its value source — a renamed tfvars file, a missingTF_VAR_env var, or a secret that wasn’t injected into the runner.
Common error: An
output(or expression) referencing an attribute the resource doesn’t export — here a made-up.checksum:Error: Unsupported attribute on main.tf line 18, in output "file_hash": 18: value = local_file.greeting.checksum This object has no argument, nested block, or exported attribute named "checksum".Why: Terraform validates every reference against the provider’s schema for that resource type.
local_fileexportscontent_sha1,content_md5,idand so on, but there is no attribute calledchecksum, so the reference can’t resolve. The same error appears for a typo in a real attribute name.Fix: Check the resource’s attribute reference in the provider docs (or run
terraform state showon an applied resource to see the exact names), then use a real one — e.g.local_file.greeting.content_sha1.How you’d spot it in prod: “Unsupported attribute” at plan time is a config bug, not an infrastructure problem — it never reached the cloud. It usually follows a provider upgrade that renamed or removed an attribute, or a copy-paste from a different resource type.
Common error: Committing
terraform.tfstateto git — because state stores every attribute, including secrets, in plain text:$ grep -A1 '"password"' terraform.tfstate "password": "S3cr3t-Db-Pass", "port": 5432,Why: State is a literal record of what Terraform built, so a database password, access key or token set on a resource is written to
terraform.tfstateverbatim — not hashed, not encrypted. Once that file is committed, the secret lives in git history forever, even after you delete the file in a later commit.Fix: Add
terraform.tfstate,terraform.tfstate.backupand.terraform/to.gitignorebefore your first commit. For anything beyond a solo local experiment, use a remote backend that encrypts state at rest and keeps it out of the repo entirely. If a secret already landed in history, rotate it — scrubbing the commit is not enough.How you’d spot it in prod: A secret-scanning alert (GitHub push protection, gitleaks) firing on a
.tfstatefile in a pull request, or a plaintext credential surfacing in a repo, means state was committed. Treat the exposed secret as compromised and rotate it immediately.
Terraform State & Variables Interview Questions
These are among the most common Terraform screening questions — variables and precedence, what state actually is, and when to make it remote. Cover each answer, say your own version out loud first, then compare; recalling before revealing is what makes them stick for interview day. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~20 more minutes today:
- 5 min —
cat terraform.tfstateafter an apply and read the JSON: find your resource, its attributes, and the top-levelserialandlineagefields Terraform uses to track versions of the state. - 5 min — Add a
sensitive = trueoutput (e.g. echoingvar.content) and re-run apply; watch Terraform printfile_path = <sensitive>and refuse to show it until you ask withterraform output -raw. - 10 min — Skim the Terraform docs’ Backends and state pages to see how a
backendblock points the same config at remote, locked state — the team setup you’ll meet on day 79.
How do you pass values into a Terraform configuration, and what wins if a variable is set in two places? Both
You declare inputs with variable blocks — each has a type and an optional default — and read them as var.name. You supply values three ways: a default in the block, a terraform.tfvars file Terraform auto-loads, or a -var (or -var-file) flag on the command line. When the same variable is set in more than one place the most specific wins: the default is lowest, then TF_VAR_ environment variables, then terraform.tfvars, and a CLI -var overrides them all. That precedence is the whole point — one config produces a dev file and a prod file by swapping tfvars or passing -var, with no change to the code.
What is the terraform.tfstate file, and why shouldn't you edit it by hand? Both
State is Terraform's memory — a JSON file that maps each resource in your config to the real object it created, plus that object's current attributes. On the next apply Terraform reads state, compares it to your config and to reality, and plans only the difference. That's why it's the source of truth: delete it and Terraform forgets it owns anything and tries to recreate duplicates. You don't hand-edit it because one wrong field silently desyncs Terraform's picture from reality, and the next apply then acts on that bad picture — deleting or recreating live resources. If you must change state, use the terraform state subcommands like mv, rm and show, which edit it safely and keep it internally consistent.
What are output values for, and how do you read a single one? Both
An output block surfaces a value after apply — usually a computed attribute of a resource, like its id, an IP address, or a URL. Outputs serve three audiences: a human reading the apply summary, another module that consumes the value, and a script. terraform output prints them all; terraform output -raw NAME prints one value with no quotes or JSON wrapping, so you can pipe it straight into another command. Without outputs you'd dig through state to find what Terraform built. They're the clean, declared interface to the results — and because they can expose sensitive data, you mark those outputs sensitive = true so Terraform redacts them from the console.
Why do teams move Terraform state to a remote backend instead of a local file? Product
A local terraform.tfstate is fine for one person, but it breaks the moment two engineers share infrastructure: each has a separate copy, both think they own the resources, and their applies silently overwrite each other. A remote backend fixes that — one shared copy of state everyone reads and writes, with state locking so only one apply runs at a time, plus version history you can roll back. HCP Terraform offers this as a managed service; a cloud object-store backend does it self-hosted. It also keeps the secrets baked into state off individual laptops. My rule of thumb: the moment a second person or a CI pipeline runs apply, state moves remote.
Mark Day 77 complete
Tomorrow you stop repeating yourself: modules package a chunk of config for reuse, and workspaces let one configuration manage several environments from the same code.
Stuck on today’s lab? Ask in Mission 90 Q&A