In this episode we'll make Terraform configuration flexible and reusable through input variables, simplify repeated expressions with local values, and surface important post-apply information through output values.

After discussing in episode 3 how providers become the bridge to cloud APIs, resources create infrastructure, and data sources read existing infrastructure — in this episode we'll turn a configuration that was previously "rigid" into something flexible and reusable through three key mechanisms: input variables, local values, and output values.
Imagine you wrote a configuration to create an EC2 instance. Now you need to create a similar instance for both the staging and production environments. Would you copy-paste the entire file and change a few values inside it? That's the beginning of a duplicated code nightmare — and precisely the problem that most often triggers mistakes in the real world. Variables exist to solve this: one set of code, many variations of values.
But why three mechanisms? Because each has a different role: variables to accept external input (flexibility between environments), locals to compute/store values inside the configuration (the DRY principle), and outputs to display/share results to humans or other modules. Knowing when to use which is the mark of a mature IaC practitioner, not just a beginner who memorizes syntax.
Input variables are how Terraform accepts values from outside the configuration files. Think of them like form fields: your code is the form, variables are the columns, and the values filled in can vary depending on who fills them and for what purpose.
The variable block is defined with the following syntax:
variable "variable_name" {
type = data_type
default = default_value
description = "explanation for humans"
validation {
condition = condition_expression
error_message = "error message when the condition fails"
}
}A real example in a clean, production-grade variables.tf file:
variable "project_name" {
type = string
default = "myapp"
description = "Project name used as a prefix for resource naming"
}
variable "environment" {
type = string
description = "Deployment environment (dev, staging, prod)"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "The environment value must be one of: dev, staging, prod."
}
}
variable "instance_type" {
type = string
default = "t3.micro"
description = "EC2 instance type"
validation {
condition = can(regex("^t[0-9]", var.instance_type))
error_message = "For lightweight workloads use the t-series instance family."
}
}
variable "tags" {
type = map(string)
default = {}
description = "Additional tags that will be merged into all resources"
}Key points from the example above:
type — enforces the data type. A wrong type (for example filling a number into a string) will be rejected by Terraform before apply.default — the default value if no value is provided from outside. A field without default is a required field; if left empty, Terraform will ask interactively — which is disruptive in automated environments like CI/CD.validation — a guardrail that rejects nonsensical values early. This is far better than a runtime error that only appears in the middle of terraform apply.Important
Every variable must have a description. This isn't just aesthetics — this documentation is what teammates will see and what terraform-docs uses to generate module documentation. A variable without a description is a sign of code that isn't ready for a team.
Terraform supports eight basic types you must master:
| Type | Value Shape | Example |
|---|---|---|
string | Text | "t3.micro" |
number | Number | 2 |
bool | Boolean | true |
list(<type>) | Ordered collection | ["ap-southeast-1a", "ap-southeast-1b"] |
set(<type>) | Unique collection (order not important) | ["web", "db"] |
map(<type>) | Key-value pairs | { Name = "myapp" } |
object({...}) | Structure with typed fields | { region = "ap-southeast-1", count = 3 } |
tuple([...]) | Collection with different types per position | ["sg", 2, true] |
Tip
A rule of thumb used by many teams: use list for lists of similar things, map for labels/tags, set for things that must be unique, and object when the data has a mixed structure — for example database config that has engine, version, and allocated_storage at once.
Variable values can be provided through three main routes. All three produce the same result — they only differ in "where the value comes from":
project_name = "myapp"
environment = "production"
instance_type = "t3.large"Explanation of each method:
.tfvars file — the most common and neatest way for environment-based config. The terraform.tfvars file is read automatically by Terraform without needing extra flags. For values specific to a developer or CI, use *.auto.tfvars files so they're read automatically without an explicit flag.-var CLI flag — suitable for ad-hoc values you don't want to store in a file, for example secrets that already exist in the CI environment.TF_VAR_ environment variable — the safest pattern for secrets: the value stays in an environment variable / secrets manager, the code only contains the variable name.Note
Because terraform.tfvars and *.auto.tfvars files often contain secret values (database passwords, API keys), make sure both are in .gitignore. Use terraform.tfvars.example as a template that can be committed, then copy it to a local terraform.tfvars.
What if the same value is given through several methods at once? Terraform has a priority rule that is consistent and deterministic, from lowest to highest priority:
| Priority | Value Source |
|---|---|
| 1 (lowest) | TF_VAR_* environment variables |
| 2 | terraform.tfvars file |
| 3 | terraform.tfvars.json file |
| 4 | *.auto.tfvars / *.auto.tfvars.json files (alphabetical order) |
| 5 (highest) | -var / -var-file CLI flags |
So if TF_VAR_environment=dev, terraform.tfvars contains environment = "staging", and you run terraform apply -var="environment=production", the winner is production (the CLI flag). Conversely, if only TF_VAR_ is set, that env value is used.
Warning
This order often surprises people. The most common team mistake: terraform.tfvars contains production values, then someone runs apply on their laptop without realizing they're using those values. The safe pattern recommended by many teams: the terraform.tfvars file only contains empty placeholders, and the real values are injected via environment/CI depending on the environment.
If variable accepts values from outside, then locals stores values computed or defined inside the configuration. Its job is to eliminate repetition (DRY — Don't Repeat Yourself): the same expression is written once, then used in many resources.
A classic example: many resources need a prefix-name-environment pattern and the same set of tags:
locals {
name_prefix = "${var.project_name}-${var.environment}"
common_tags = {
Name = local.name_prefix
Environment = var.environment
ManagedBy = "terraform"
}
}Then those values are used in many places without repeating the logic:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = merge(var.tags, local.common_tags)
}
resource "aws_s3_bucket" "artifacts" {
bucket = "${local.name_prefix}-artifacts"
tags = merge(var.tags, local.common_tags)
}Notice two things:
local.name_prefix and local.common_tags are referenced with the local keyword — and now both resources are automatically in sync. If the naming rule changes, you only need to change one place.merge() combines input-variable tags with the built-in tags — a small example of how functions (which we'll dive into in episode 6) work together with locals.Caution
locals can't be overridden from outside (unlike variable), and can't be exported to other modules (only output can). If a value must differ between environments, make it a variable, not a locals. If the value is purely derived from other variables inside the configuration, use locals.
After terraform apply finishes, how do you know the public IP of the newly created instance, or the database endpoint? Two options: log into the cloud console (slow and error-prone), or — much better — declare an output value.
output is Terraform's mechanism for displaying important post-apply information and exposing values for other modules to consume. Its syntax is simple:
output "instance_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "database_endpoint" {
description = "RDS database connection endpoint"
value = aws_db_instance.main.endpoint
sensitive = true
}
output "website_url" {
description = "Application access URL"
value = "http://${aws_instance.web.public_ip}"
}How to read them after apply:
terraform outputinstance_public_ip = "54.169.120.11"
website_url = "http://54.169.120.11"terraform output instance_public_ip
terraform output -jsonKey points:
value can be an expression, not just a static attribute — look at website_url which concatenates a string.sensitive = true hides the value from terminal and log output, for things like database credentials. Note: this only hides the display, not storage encryption — we'll dive into state file security in episode 15.To see the big picture, here's a complete configuration that can now be used for both staging and production just by changing variable values:
variable "environment" {
type = string
description = "Deployment environment"
}
variable "instance_type" {
type = string
default = "t3.micro"
}locals {
name_prefix = "myapp-${var.environment}"
}resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = {
Name = local.name_prefix
Environment = var.environment
}
}
output "instance_public_ip" {
value = aws_instance.web.public_ip
}For different environments, just use a different tfvars file:
terraform apply -var-file="staging.tfvars"terraform apply -var-file="production.tfvars"Tip
This is the essence of good Infrastructure as Code: configuration (code) is separate from environment (data). A team can review the code once, while environment values are managed separately — far safer and more auditable than copy-pasting configuration files.
| Mistake | Symptom | Solution |
|---|---|---|
Forgetting to provide a value for a variable without default | Terraform asks for interactive input — stalls in CI/CD | Provide a default or inject the value via TF_VAR_/-var |
| Wrong data type | Error Inappropriate value for attribute | Use the correct type, e.g. number instead of string |
Forgetting description | Code hard to understand, module docs not generated | Always fill in description |
Misusing variable vs locals | A value that should be constant can change between environments | locals for internal derivations, variable for external input |
Writing secrets in a committed terraform.tfvars | Credential leak into the repository | Put it in .gitignore, use env variables |
Assuming sensitive encrypts data | Value is still stored in plaintext in state | Understand that sensitive only hides the display |
Typos in local/output references | Error Reference to undeclared | Verify block names; run terraform validate |
In this episode 4 we discussed three mechanisms that make Terraform code truly alive: input variables to accept values from outside (.tfvars, -var, TF_VAR_) with a clear priority order, local values to eliminate expression repetition following the DRY principle, and output values to display and share post-apply work results.
The ability to tell when to use variable, locals, and output is one of the main differentiators between code that merely "works" and code that's "team-ready". With this foundation, a single set of your configurations can now serve many environments without sacrificing security.
In the next episode 5, we'll discuss a topic often considered scary but mandatory to master: State Management (Local vs Remote State) — why Terraform stores the terraform.tfstate file, why storing it locally is dangerous for a team, and how remote backends with state locking save teams from disaster. Stay excited!