Variables & Outputs

Make your configurations flexible and reusable with variables

8 min read

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:

  1. -var command line flag
  2. -var-file flag
  3. *.auto.tfvars files (alphabetical)
  4. terraform.tfvars
  5. Environment variables (TF_VAR_*)
  6. 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!