Variables & Outputs: Making Configuration Reusable
Hardcoded Configs Are Dead on Arrival
A Terraform config with hardcoded values — a specific AMI ID, a specific instance type, a specific region — is a one-time-use script. It works for a lab exercise. It’s useless for anything that needs to work across environments, regions, or teams.
Variables and outputs are what transform a static config into something reusable and composable. Variables are the inputs — the knobs you turn to customize behavior. Outputs are the return values — the data your config produces that other configs or teams can consume.
This is the same principle behind functions in programming: parameters in, return values out. The function body (your resource definitions) stays the same; the behavior changes based on inputs.
Input Variables — The Parameterization Mechanism
Declare a variable with a variable block:
variable "instance_type" {
type = string
description = "EC2 instance type for the web server"
default = "t3.micro"
}
variable "environment" {
type = string
description = "Deployment environment (dev, staging, prod)"
# No default — the caller MUST provide this
}
variable "allowed_cidrs" {
type = list(string)
description = "CIDRs allowed to reach the web server"
default = ["10.0.0.0/8"]
}
Use variables with the var. prefix:
resource "aws_instance" "web" {
instance_type = var.instance_type
tags = {
Environment = var.environment
}
}
The difference between a variable with a default and one without is significant. Variables without defaults are required — Terraform will error if no value is provided. Variables with defaults are optional — the default is used if the caller doesn’t override it. This distinction lets you design interfaces where some parameters must be specified (like environment) while others have sensible defaults (like instance_type = "t3.micro").
Always include type and description on every variable. type catches misconfigurations early (passing a string where a number is expected). description documents the variable’s purpose for anyone reading the code — including your future self when you revisit this config six months later.
Variable Types — From Simple to Structured
| Type | Example Value | When to Use |
|---|---|---|
string | "t3.micro" | Single values: names, regions, instance types |
number | 3 | Counts, sizes, port numbers |
bool | true | Feature flags: enable/disable functionality |
list(string) | ["us-east-1a", "us-east-1b"] | Ordered collections: AZs, CIDRs, tags |
map(string) | {env = "prod", team = "infra"} | Key-value pairs: tags, environment variables |
set(string) | (like list, but unordered and unique) | When you need uniqueness guarantees |
object({...}) | Complex structured type | When you need a specific shape with named fields |
tuple([...]) | Fixed-length list with mixed types | Rarely used; prefer object |
any | Accepts anything | Flexibility at the cost of type safety |
For most configs, string, number, bool, list, and map cover everything you need. Complex types like object become important when you’re building reusable modules that accept structured configuration — like a list of ingress rules, each with a port, protocol, and CIDR.
The sensitive Flag — Hiding Values from Output
Mark a variable as sensitive when it contains something that shouldn’t appear in terminal output:
variable "db_password" {
type = string
description = "Database master password"
sensitive = true
}
What sensitive = true actually does:
– Terraform redacts the value in plan and apply output: (sensitive value)
– Any output that references this variable is also automatically treated as sensitive
– The value propagates sensitivity through modules — if you pass a sensitive variable into a child module, the child’s use of it is also redacted
What sensitive = true does NOT do:
– It does not encrypt the value in the state file. The state file stores it in plaintext JSON.
– It does not prevent the value from being used in resource arguments — the cloud API still receives the actual value.
– It does not prevent someone from running terraform output -json and extracting sensitive outputs (though they’ll need state access).
The implication: sensitive = true is output suppression, not encryption. True secrets protection requires encrypted remote state (S3 with server-side encryption, HCP Terraform’s encrypted state, etc.) and proper access controls on the state file.
Variable Validation — Catching Bad Input at Plan Time
You can enforce constraints on variable values before any infrastructure changes happen:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "instance_count" {
type = number
validation {
condition = var.instance_count > 0 && var.instance_count <= 20
error_message = "Instance count must be between 1 and 20."
}
}
If validation fails:
Error: Invalid value for variable
│ on variables.tf line 1:
│ Environment must be one of: dev, staging, prod.
Why validation matters: without it, a bad input flows all the way to the apply phase before failing — possibly after partial infrastructure changes have already been made. With validation, you catch the problem at plan time, before anything is touched. The earlier you catch errors, the cheaper they are.
Add validation to every variable that has a constrained set of valid values: environment names, instance counts, CIDR ranges, region names. It’s especially valuable for shared modules where the callers might not understand the valid input range.
Output Values — Returning Data from Your Config
Outputs expose information from your Terraform configuration. They serve two purposes: displaying results after apply, and passing data between modules or workspaces.
output "instance_public_ip" {
value = aws_instance.web.public_ip
description = "Public IP of the web server"
}
output "db_endpoint" {
value = aws_db_instance.main.endpoint
description = "Database connection endpoint"
sensitive = true # Suppress from output display
}
After terraform apply:
$ terraform output
instance_public_ip = "54.123.45.67"
db_endpoint = <sensitive>
# Get a specific output
$ terraform output instance_public_ip
"54.123.45.67"
# Get all outputs in JSON format (useful for scripting)
$ terraform output -json
Outputs from a root module are displayed in the terminal after apply. Outputs from a child module are accessed via module.<name>.<output_name> and are NOT automatically displayed — they’re communication channels between modules, not terminal output.
Outputs as Inter-Module Communication
When you compose modules, outputs are the mechanism for passing data between them:
# modules/networking/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
# root/main.tf
module "networking" {
source = "./modules/networking"
}
module "compute" {
source = "./modules/compute"
vpc_id = module.networking.vpc_id # Pass output as input
subnet_ids = module.networking.private_subnet_ids # Pass list output
}
This is how you build composable infrastructure. The networking module doesn’t know or care about the compute module. It just produces outputs. The root module wires them together. This separation means each module can be developed, tested, and versioned independently.
How to Supply Variable Values
There are multiple ways to provide values, and they follow a strict precedence order:
1. Default in variable block (lowest priority):
variable "region" {
default = "us-east-1"
}
2. terraform.tfvars file (auto-loaded):
# terraform.tfvars
environment = "prod"
instance_type = "t3.large"
3. *.auto.tfvars files (auto-loaded, alphabetically):
# prod.auto.tfvars
region = "us-west-2"
4. -var-file flag:
terraform apply -var-file="prod.tfvars"
5. -var command-line flag:
terraform apply -var="environment=prod"
6. TF_VAR_ environment variables (highest priority):
export TF_VAR_environment=prod
terraform apply
The precedence order from lowest to highest: default → terraform.tfvars → *.auto.tfvars → -var-file → -var → TF_VAR_. Each source overrides the ones below it.
Why does this matter? In CI/CD, you often use TF_VAR_ environment variables for secrets (injected by the pipeline) and -var-file for environment-specific configs. The environment variables take highest precedence, ensuring pipeline-injected secrets always win.
Design Scenario: One Config, Multiple Environments
# variables.tf
variable "environment" { type = string }
variable "instance_type" {
type = string
default = "t3.micro"
}
# main.tf
resource "aws_instance" "app" {
instance_type = var.instance_type
tags = {
Environment = var.environment
Name = "${var.environment}-app-server"
}
}
# outputs.tf
output "app_server_ip" {
value = aws_instance.app.public_ip
}
# Deploy to dev (uses default t3.micro)
terraform apply -var-file="envs/dev.tfvars"
# Deploy to prod (overrides to t3.xlarge)
terraform apply -var-file="envs/prod.tfvars"
Where prod.tfvars has instance_type = "t3.xlarge" and environment = "prod". Same code, different inputs, different infrastructure. Same type safety, same validation, same outputs — just scaled differently.
Key Takeaways
Variables without defaults are required parameters. Variables with defaults are optional. This distinction is your primary tool for designing module interfaces — require what must be specified, default what has a sensible common value.
sensitive = true is output suppression, not encryption. The value is still in the state file in plaintext. Protect secrets by encrypting state storage, not by relying on the sensitive flag alone.
Validation blocks are your first line of defense against misconfiguration. A variable with contains(["dev", "staging", "prod"], var.environment) prevents someone from accidentally deploying to an environment called “prodd” — a typo that would create an entirely new, unintended infrastructure stack.
Outputs serve dual purposes: displaying results after apply and passing data between modules. In a root module, they’re informational. In a child module, they’re the API surface — the only way for the parent to access the child’s internal resource attributes.

