Variables, outputs & locals
Parameterize and expose values.
A working kitchen runs on three kinds of paper. The order ticket comes in from the front of house and says what this one customer wants: table six, no chilli, extra bread. The prep notes taped above the bench are the kitchen's own shorthand, worked out once so nobody recalculates the brine ratio at eight o'clock on a Friday. The receipt goes out at the end and tells whoever is next in the chain what actually happened. Terraform has all three. They are called input variables, locals, and outputs.
An input variable is a value your configuration accepts from outside. A local is a named expression worked out inside the configuration and reused. An output is a value the configuration hands back when an apply finishes: printed to your terminal, and (this is the part people miss) written into the state file, which is Terraform's own record of everything it has built. Other configurations and other teams can read that record. In, inside, out. Hold on to that direction, because two of the three are places secrets escape, and one of them is a place attackers go looking.
variable "environment" {type = stringdescription = "Deployment environment. Drives naming, sizing and tags."nullable = false # no default here, so an explicit null is an errorvalidation {condition = contains(["dev", "staging", "prod"], var.environment)error_message = "The environment must be dev, staging, or prod."}}variable "instance_type" {type = stringdefault = "t3.micro" # a default is what makes a variable optionaldescription = "EC2 size for the web tier (Elastic Compute Cloud, an AWS virtual machine)"}variable "ssh_allowed_cidrs" {type = list(string) # no default: the caller MUST decidedescription = "Source ranges permitted to reach port 22"validation {condition = length(var.ssh_allowed_cidrs) > 0error_message = "At least one source range is required."}validation {condition = alltrue([for c in var.ssh_allowed_cidrs : can(cidrnetmask(c)) && c != "0.0.0.0/0"])error_message = "Every entry must be a valid IPv4 CIDR, and 0.0.0.0/0 is not allowed for SSH."}}variable "grafana_api_token" {type = stringsensitive = true # redacted from plan and apply outputdescription = "Token the monitoring provider authenticates with"}
Validation Is a Guard on the Door
A variable block with nothing but a type is a door with no guard. Anything that parses gets through. A validation block puts someone on that door: a condition that has to come out true, and a message the operator reads when it does not. You already have this instinct about user input in an application. Do not take a string on trust. Check it at the boundary, fail loudly, and fail before anything gets built.
The security payoff arrives fast. The contains() rule stops a typo like "produciton" from spawning a whole parallel set of resources that no cost report and no policy rule will ever match, because every name and tag derived from that value is now spelled wrong. The second rule is better still. CIDR (Classless Inter-Domain Routing, the 10.0.0.0/8 way of writing a whole range of IP addresses in one go) is easy to fumble by hand. The function can() runs an expression and hands back false instead of blowing up when that expression errors, so can(cidrnetmask(c)) is a cheap test for "is this a real IPv4 range at all". The clause beside it refuses 0.0.0.0/0, which means the entire internet, so nobody opens SSH (Secure Shell, the encrypted remote login protocol that answers on port 22) to the world by pasting one careless line into a values file. That rule lives in code, gets reviewed like code, and binds every caller.
Two details separate a rule from a rule that works. First, alltrue() returns true for an empty list, so a caller passing ssh_allowed_cidrs = [] would sail past the CIDR check untouched. The length() rule above it is what closes that hole. Second, one variable can carry as many validation blocks as you want, each with its own message, which beats welding three conditions into a single unreadable expression. Before Terraform 1.9 a condition could only look at its own variable. From 1.9 onward it can also read other variables, locals and data sources, so you can write rules that span the whole configuration, like "prod is not allowed to use a public subnet".
$ terraform plan -var-file=prod.tfvars ; echo "exit=$?"
╷│ Error: Invalid value for variable││ on prod.tfvars line 1:│ 1: environment = "produciton"││ The environment must be dev, staging, or prod.││ This was checked by the validation rule at variables.tf:6,3-13.╵exit=1
Look at what did not happen. No plan was produced, nothing was created and then rolled back, and the shell got back exit status 1, which is exactly what you want a pipeline step to see. One companion flag belongs here. In CI (continuous integration, the automation that runs on every change you push), pass -input=false to plan and apply. A missing required variable then fails on the spot, instead of Terraform stopping to ask a question that no human is sitting there to answer.
Where the Value Actually Comes From
You read a variable as var.name. Supplying its value is where teams get burned, because five different places can set one and they sit on a ladder. Weakest first: the default inside the variable block; a TF_VAR_name environment variable; a terraform.tfvars file auto-loaded from the working directory (terraform.tfvars.json is read after it, so the JSON version wins if you somehow have both); any file ending in .auto.tfvars, also auto-loaded, in lexical order of filename so later names beat earlier ones; and at the top, -var or -var-file on the command line, which beats everything else. Pass -var twice on one command line and the last one wins. A file called prod.tfvars is never auto-loaded. It counts only when you pass -var-file=prod.tfvars, which is also what puts it on the top rung.
Read that ladder again, because the popular belief is upside down. Environment variables sit second from the bottom, not the top. A values file committed to the repository outranks the TF_VAR_ value your pipeline exported so carefully.
# Auto-loaded from the working directory. No flag needed, and no way to opt out.environment = "prod"instance_type = "t3.micro"ssh_allowed_cidrs = ["10.20.0.0/16", "203.0.113.7/32"]
$ export TF_VAR_instance_type=t3.large # surely the environment wins?$ terraform plan -no-color | grep 'instance_type '
+ instance_type = "t3.micro"
The environment variable lost. The file won, and nothing on screen said so. This is a real incident pattern: somebody commits a terraform.tfvars holding dev-sized values, the pipeline exports the prod values as environment variables, and production comes up on the wrong instance size or, worse, with the wrong allowed CIDR list. Terraform never announces that two sources disagreed. It applies the ranking and carries on. When you need certainty about which value wins, put it on the command line.
$ terraform plan -no-color -var='instance_type=t3.small' | grep 'instance_type '
+ instance_type = "t3.small"
So why reach for environment variables for secrets at all, when they lose every fight with a file? Because of what the alternative leaves lying around on the machine. A value passed with -var goes into your shell history and into the running process's command line, and on Linux a process's command line is public reading. The bracket trick in the first command below, [t]erraform, stops the grep from matching itself.
$ ps -ww -eo user,pid,args | grep '[t]erraform apply'$ stat -c '%a %U %n' /proc/48213/cmdline /proc/48213/environ
deploy 48213 terraform apply -auto-approve -var=db_password=Wint3r-2026!444 deploy /proc/48213/cmdline400 deploy /proc/48213/environ
Those two file modes are the whole argument. /proc is a pretend filesystem that the kernel (the core of the operating system, the part that owns the hardware and the process table) keeps in memory, with one numbered directory per running process. Inside it, cmdline is mode 444: readable by every account on the box, including a low-privileged service account or a build agent an attacker already owns. environ is 400: the process owner and root, nobody else. That makes TF_VAR_db_password meaningfully harder to steal off a shared runner than -var=db_password=..., with two caveats that keep the advice honest. If every CI job on that runner executes as the same user, that user reads every job's environ and the advantage evaporates. And child processes inherit the environment, so every provider plugin Terraform starts receives the whole set. A hardened host can mount /proc with hidepid=invisible (spelled hidepid=2 on older kernels) to hide other users' process directories entirely; Ubuntu 22.04 and Debian 12 leave them visible by default.
The same problem has a bigger cousin. A secret passed with -var stays behind in ~/.bash_history after the run, and it also lands in the saved plan when you run terraform plan -out=tfplan, because a plan file records the resolved value of every variable in the clear, ready to be read back with terraform show -json tfplan. Treat tfplan as a secret, never as a build artifact you attach to a pipeline job or paste into a ticket. Put *.tfvars, *.tfvars.json and tfplan into .gitignore on day one, and allowlist back only the values files you have read and confirmed hold nothing sensitive. The workable combination for secrets: pass them as TF_VAR_ environment variables, and make certain no tfvars file in the repo declares those same names.
Locals Are the Prep Bench
A local is a name for an expression you would otherwise write out again and again. Write it once, use it everywhere, change it in one place. Unlike a variable, nobody outside can set a local: it is worked out during the run from variables, resource attributes and other locals. The most valuable everyday use has nothing to do with clever logic. It is consistency.
locals {name_prefix = "acme-${var.environment}" # worked out once, reused everywherecommon_tags = {Environment = var.environmentManagedBy = "terraform"Owner = "platform-team"Repo = "github.com/acme/infra-web"}}resource "aws_security_group" "web" {name = "${local.name_prefix}-web"ingress {from_port = 22to_port = 22protocol = "tcp"cidr_blocks = var.ssh_allowed_cidrs # already validated on the way in}tags = local.common_tags}resource "aws_instance" "web" {ami = "ami-0abcdef1234567890"instance_type = var.instance_typevpc_security_group_ids = [aws_security_group.web.id]tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })}
Every resource here carries the same four tags, because they all read the same map. That matters at three in the morning. When something odd is scanning your VPC (virtual private cloud, your own walled-off network inside the cloud provider) and you find a machine you do not recognise, the gap between "Owner: platform-team, Repo: github.com/acme/infra-web" and no tags at all is the gap between a five-minute answer and an hour of asking around on Slack. Tag-driven controls in AWS Config, Azure Policy or your own cleanup script only work when the tags are genuinely on everything, and one local.common_tags (wrapped in merge() where a resource needs to add its own Name) is how you get that. It also kills copy-paste drift: one name prefix means your bucket, your security group and your instance cannot end up in three different naming schemes.
Three limits are worth knowing. Locals are recomputed from scratch on every plan, so a local is not a cache and not a home for anything random or time-based. A timestamp() buried in one that feeds a resource argument will show you a change on every single run, forever. Locals also cannot reference themselves, directly or around a circle; Terraform spots the cycle and refuses to start rather than looping. And a local is not a hiding place. The local itself never appears in state, but the moment its value lands in a resource argument, that argument sits in state like any other.
Outputs Are a Publishing Decision
An output is the pickup counter at the end of the line. It prints when an apply finishes, and it is saved into state, which turns it from a print statement into a publishing decision. A separate configuration can read your outputs through the terraform_remote_state data source, which is how a network stack hands a VPC id to an application stack without either team hard-coding it. Everything you output is something you have agreed to expose to whoever can reach that state.
output "public_ip" {description = "Public address of the web tier"value = aws_instance.web.public_ip}output "security_group_id" {description = "Read by the app stack through terraform_remote_state"value = aws_security_group.web.id}output "db_password" {value = random_password.db.result # the random provider already marks this sensitivesensitive = true # redacted on screen. Still plaintext in state.}
$ terraform output$ terraform output -raw public_ip$ terraform output -json | jq '.db_password'
db_password = <sensitive>public_ip = "54.210.18.7"security_group_id = "sg-0a1b2c3d4e5f60718"54.210.18.7{"sensitive": true,"type": "string","value": "8fQ!vR2mZk7pLt"}
Three commands, three lessons. Plain terraform output redacts the sensitive one. The -raw flag prints a single value with no quotes and no trailing newline, which is why it pipes cleanly into another command and why it is the flag scripts reach for. And -json, piped here through jq (a small command-line tool for picking values out of JSON, the plain-text data format Terraform emits), prints every value in full, with "sensitive": true sitting right next to the cleartext secret. The redaction is a display setting, not a permission. Anyone who can run terraform in that directory reads the secret in one command.
Terraform does carry sensitivity through expressions, which helps. The result of random_password is already marked sensitive by the random provider, so dropping it into an output without sensitive = true stops the run with "Output refers to sensitive values" and asks you to confirm the intent rather than publishing it quietly. Terraform 1.10 added something stronger: ephemeral = true on variables and outputs. An ephemeral value exists only for the length of the operation and is never written to the plan file or to state. The trade is that it can flow only into other places that do not persist, such as provider configuration, provisioner connection blocks, and the write-only resource arguments that arrived in 1.11. Ephemeral outputs belong to child modules, since a root module's outputs are meant to be recorded. All of this suits a credential passing through on its way to an API (application programming interface, the door one program knocks on to talk to another), not a value you want to keep.
Checking Your Work Before You Apply
You do not need an apply to see what your variables and locals came out as. terraform console opens a REPL (read-eval-print loop: a prompt where you type one expression and it prints the answer straight back, like a pocket calculator with your whole configuration loaded), and it accepts the same -var-file you would pass to plan.
$ terraform console
> var.environment"prod"> local.name_prefix"acme-prod"> var.ssh_allowed_cidrs["10.20.0.0/16","203.0.113.7/32",]> local.common_tags["Owner"]"platform-team"> can(cidrnetmask("0.0.0.0/0"))true> exit
That last line is the point of the exercise. 0.0.0.0/0 is a perfectly valid CIDR, so can(cidrnetmask(...)) says true and would have waved it through on its own. The explicit second clause in the validation rule is what actually blocks it. A syntax check is not a policy check, and the console is where you find out which of the two you wrote. Use the same loop to confirm a name prefix before it becomes forty resource names, and to see which values file really supplied a value. The console reads your state and evaluates expressions. It creates nothing and changes nothing.
$ git log --oneline -- '*.tfvars' | head -3$ terraform output -json | jq -r 'to_entries[] | select(.value.sensitive | not) | .key'
9c41f0a Add prod values file for the new regionpublic_ipsecurity_group_id
Two checks worth running before you merge anything that touches variables or outputs. The first shows whether somebody has committed a values file that will quietly outrank your pipeline's environment variables. The second lists every value you are publishing into state without redaction, which is the list you should be able to justify line by line.
Treat a new output the way you treat a new firewall rule: someone has to be able to say who reads it and why. The output you added for five minutes of debugging is still there six months later, still sitting in state, still readable by every principal with access to that bucket, and nobody left on the team remembers it exists.
Try this
Run terraform plan -var-file=prod.tfvars ; echo "exit=$?" 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: sensitive hides the screen, not the file. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.