Variables & Outputs
Make your configurations flexible and reusable with variables
Variables & Outputs
In the previous tutorial, we learned HCL syntax and built resources. But everything was hardcoded ā like writing a recipe that only works for exactly 4 people. Real projects need flexibility.
Variables let you customize configs without editing code. Outputs let you extract useful information from your infrastructure. Together, they make your Terraform actually reusable.
Input Variables
Defining Variables
Create a variables.tf file ā this is where you declare what inputs your config accepts:
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "environment" {
description = "Environment name"
type = string
}
Using Variables
Reference them with var.name ā simple as that:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type
tags = {
Environment = var.environment
}
}
Variable Attributes
"What else can I configure on a variable?"
Quite a lot actually:
variable "instance_count" {
description = "Number of instances to create" # What it's for
type = number # Data type
default = 1 # Default value
sensitive = false # Hide from logs?
nullable = false # Allow null?
validation { # Custom validation
condition = var.instance_count > 0
error_message = "Instance count must be positive."
}
}
Variable Types
Primitive Types
variable "name" {
type = string
}
variable "count" {
type = number
}
variable "enabled" {
type = bool
}
Collection Types
# List - ordered collection
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
# Set - unordered unique values
variable "allowed_ips" {
type = set(string)
}
# Map - key-value pairs
variable "tags" {
type = map(string)
default = {
Project = "demo"
}
}
Complex Types
# Object - structured data
variable "database" {
type = object({
engine = string
engine_version = string
instance_class = string
storage = number
multi_az = bool
})
default = {
engine = "postgres"
engine_version = "15.3"
instance_class = "db.t3.micro"
storage = 20
multi_az = false
}
}
# Tuple - fixed-length list with specific types
variable "server_config" {
type = tuple([string, number, bool])
# Example: ["web", 2, true]
}
Any Type
When you don't want to constrain (the "I'll accept anything" approach):
variable "custom_config" {
type = any
}
Use sparingly ā type constraints catch errors early. It's like wearing a seatbelt: slightly annoying, but saves you when things go wrong.
Setting Variable Values
"Okay, I defined variables. But how do I actually pass values in?"
Five different ways. Seriously. Let's go through them:
variable "region" {
default = "us-east-1"
}
Method 2: terraform.tfvars
Create terraform.tfvars:
environment = "production"
instance_type = "t2.small"
instance_count = 3
Terraform loads this automatically.
Method 3: Named .tfvars Files
# production.tfvars
environment = "production"
instance_type = "t2.large"
# staging.tfvars
environment = "staging"
instance_type = "t2.small"
Specify at runtime:
terraform apply -var-file="production.tfvars"
Method 4: Command Line
terraform apply -var="environment=production" -var="instance_count=5"
Method 5: Environment Variables
export TF_VAR_environment="production"
export TF_VAR_instance_count=5
terraform apply
Prefix with TF_VAR_. Terraform picks them up automatically. Great for CI/CD pipelines.
Precedence Order
"What if the same variable is set in multiple places?"
Highest to lowest priority:
-varcommand line flag-var-fileflag*.auto.tfvarsfiles (alphabetical)terraform.tfvars- Environment variables (
TF_VAR_*) - Default value in variable block
Variable Validation
"Can I prevent people from passing in garbage values?"
Absolutely. Catch bad input early before it creates bad infrastructure:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "instance_type" {
type = string
validation {
condition = can(regex("^t[23]\\.", var.instance_type))
error_message = "Only t2 or t3 instance types are allowed."
}
}
variable "cidr_block" {
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
Multiple validations are allowed:
variable "port" {
type = number
validation {
condition = var.port >= 1 && var.port <= 65535
error_message = "Port must be between 1 and 65535."
}
validation {
condition = var.port != 22 && var.port != 3389
error_message = "Ports 22 and 3389 are not allowed."
}
}
Sensitive Variables
"What about passwords? I don't want those showing up in logs."
Smart thinking. Mark variables as sensitive:
variable "db_password" {
type = string
sensitive = true
}
Plan output shows:
+ password = (sensitive value)
But sensitive doesn't encrypt ā it just hides from logs. It's like putting a sticky note over a password on your monitor. You still need to store secrets properly (we'll cover this later).
Local Values
Locals are computed values used within a module. Think of them as "private variables" ā you define them, you use them, nobody outside needs to know:
locals {
# Simple value
project_name = "myapp"
# Computed from variables
name_prefix = "${var.environment}-${local.project_name}"
# Common tags used everywhere
common_tags = {
Project = local.project_name
Environment = var.environment
ManagedBy = "Terraform"
}
# Conditional value
instance_type = var.environment == "prod" ? "t2.large" : "t2.micro"
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = local.instance_type
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web"
})
}
Use locals to:
- Avoid repeating expressions
- Give complex expressions readable names
- Compute values from multiple variables
Output Values
"How do I see the IP address of the server I just created?"
Outputs! They expose information about your infrastructure. They display after terraform apply and can be consumed by other modules.
Defining Outputs
Create outputs.tf (keep it organized!):
output "instance_id" {
description = "The ID of the EC2 instance"
value = aws_instance.web.id
}
output "public_ip" {
description = "The public IP address"
value = aws_instance.web.public_ip
}
output "bucket_url" {
description = "The S3 bucket URL"
value = "https://${aws_s3_bucket.app.bucket}.s3.amazonaws.com"
}
After terraform apply:
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
bucket_url = "https://my-app-bucket.s3.amazonaws.com"
instance_id = "i-0abc123def456789"
public_ip = "54.123.45.67"
Viewing Outputs
# All outputs
terraform output
# Specific output
terraform output public_ip
# JSON format (for scripts)
terraform output -json
Sensitive Outputs
output "db_password" {
value = aws_db_instance.main.password
sensitive = true
}
Output shows <sensitive>. Access with:
terraform output -raw db_password
Output Attributes
output "server_info" {
description = "Complete server information"
value = aws_instance.web.id
sensitive = false
# Only output after successful apply
depends_on = [aws_instance.web]
}
Complete Example
# variables.tf
variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "instance_count" {
description = "Number of web servers"
type = number
default = 1
}
variable "db_password" {
description = "Database password"
type = string
sensitive = true
}
# locals.tf (or in main.tf)
locals {
name_prefix = "myapp-${var.environment}"
common_tags = {
Environment = var.environment
Project = "myapp"
ManagedBy = "Terraform"
}
instance_type = {
dev = "t2.micro"
staging = "t2.small"
prod = "t2.medium"
}[var.environment]
}
# main.tf
resource "aws_instance" "web" {
count = var.instance_count
ami = "ami-0c55b159cbfafe1f0"
instance_type = local.instance_type
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web-${count.index}"
})
}
resource "aws_db_instance" "main" {
identifier = "${local.name_prefix}-db"
engine = "postgres"
instance_class = "db.t3.micro"
username = "admin"
password = var.db_password
tags = local.common_tags
}
# outputs.tf
output "web_instance_ids" {
description = "IDs of web instances"
value = aws_instance.web[*].id
}
output "web_public_ips" {
description = "Public IPs of web instances"
value = aws_instance.web[*].public_ip
}
output "database_endpoint" {
description = "Database connection endpoint"
value = aws_db_instance.main.endpoint
}
Run with:
terraform apply -var="environment=staging" -var="db_password=supersecret"
Best Practices
The wisdom section. Learn from other people's "why is this broken" moments.
1. Always Add Descriptions
variable "instance_type" {
description = "EC2 instance type for web servers" # Future you will thank you
type = string
}
Seriously, in 6 months you won't remember what half these variables are for.
2. Use Type Constraints
# Bad - anything goes
variable "port" {}
# Good - catches errors early
variable "port" {
type = number
}
3. Provide Sensible Defaults
variable "instance_type" {
type = string
default = "t2.micro" # Works for most dev scenarios
}
4. Use Locals for Repeated Expressions
# Bad - repeated everywhere
tags = {
Project = "myapp"
Environment = var.environment
ManagedBy = "Terraform"
}
# Good - defined once
tags = local.common_tags
5. Organize Files
project/
āāā variables.tf # All variable definitions
āāā locals.tf # Local values
āāā outputs.tf # All outputs
āāā main.tf # Resources
āāā terraform.tfvars # Default values
What's Next?
Look at you ā your configs are now flexible and reusable! You know:
- How to define variables with types and validation
- Five ways to pass values in
- How to use locals for computed values
- How to extract info with outputs
But where does Terraform store all this information about your infrastructure? Let's talk about state management ā arguably the most important concept in Terraform. Let's go!