Variables are inputs — values passed into your configuration. Outputs are results — values exposed after terraform apply. Together they define how data flows in and out of Terraform modules.
Variables (Inputs)
Basic Variable
variable "instance_type" {
type = string
description = "EC2 instance type"
default = "t3.micro"
}
resource "aws_instance" "web" {
instance_type = var.instance_type
}
Variable Types
# String
variable "environment" {
type = string
default = "dev"
}
# Number
variable "instance_count" {
type = number
default = 3
}
# Boolean
variable "enable_monitoring" {
type = bool
default = true
}
# List
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
# Map
variable "instance_types" {
type = map(string)
default = {
dev = "t3.micro"
prod = "t3.xlarge"
}
}
# Object
variable "database" {
type = object({
engine = string
engine_version = string
instance_class = string
multi_az = bool
})
default = {
engine = "postgres"
engine_version = "16.3"
instance_class = "db.t3.micro"
multi_az = false
}
}
# Tuple
variable "subnet_config" {
type = tuple([string, number, bool])
# ["10.0.1.0/24", 1, true]
}
Setting Variables
# 1. CLI flag
terraform apply -var="instance_type=t3.large"
# 2. tfvars file
terraform apply -var-file="prod.tfvars"
# 3. Environment variable
export TF_VAR_instance_type="t3.large"
terraform apply
# 4. Auto-loaded files
# terraform.tfvars
# terraform.tfvars.json
# *.auto.tfvars
# *.auto.tfvars.json
# prod.tfvars
environment = "prod"
instance_type = "t3.xlarge"
instance_count = 5
enable_monitoring = true
Variable Precedence (Highest to Lowest)
-varand-var-fileon CLI*.auto.tfvars(alphabetical order)terraform.tfvarsTF_VAR_*environment variables- Variable
defaultvalue - Interactive prompt (if no default)
Validation
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("^t3\\.", var.instance_type))
error_message = "Only 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."
}
}
Sensitive Variables
variable "db_password" {
type = string
sensitive = true # Hidden in plan/apply output
}
terraform plan:
~ password = (sensitive value)
Note: sensitive = true hides the value in CLI output but does NOT encrypt it in state.
Required vs Optional
# Required — no default, must be provided
variable "vpc_id" {
type = string
description = "VPC ID to deploy into"
}
# Optional — has a default
variable "enable_logging" {
type = bool
default = true
}
Outputs
Basic Output
output "instance_id" {
description = "EC2 instance ID"
value = aws_instance.web.id
}
output "public_ip" {
description = "Public IP address"
value = aws_instance.web.public_ip
}
terraform apply
# Outputs:
# instance_id = "i-0abc123def456"
# public_ip = "54.210.167.99"
# Query specific output
terraform output public_ip
# "54.210.167.99"
# JSON format (for scripts)
terraform output -json
Sensitive Outputs
output "db_password" {
value = random_password.db.result
sensitive = true
}
terraform output db_password
# (sensitive — use terraform output -raw db_password to see)
terraform output -raw db_password
# MyS3cur3P@ss
Conditional Outputs
output "bastion_ip" {
value = var.create_bastion ? aws_instance.bastion[0].public_ip : null
description = "Bastion host IP (null if bastion disabled)"
}
Data Flow Between Modules
This is where variables and outputs work together:
Root Module
├── variables.tf (inputs from user)
├── main.tf (calls child modules)
├── outputs.tf (exposes results)
│
├── modules/networking/
│ ├── variables.tf (inputs from root)
│ ├── main.tf (creates resources)
│ └── outputs.tf (exposes VPC ID, subnet IDs)
│
└── modules/compute/
├── variables.tf (inputs from root + networking outputs)
├── main.tf (creates resources)
└── outputs.tf (exposes instance IPs)
Module Variables (Inputs)
# modules/networking/variables.tf
variable "vpc_cidr" {
type = string
}
variable "environment" {
type = string
}
Module Outputs
# modules/networking/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
Wiring Modules Together
# Root main.tf
module "networking" {
source = "./modules/networking"
vpc_cidr = var.vpc_cidr # Root variable → module input
environment = var.environment
}
module "compute" {
source = "./modules/compute"
vpc_id = module.networking.vpc_id # Module output → module input
subnet_ids = module.networking.private_subnet_ids
}
# Root outputs.tf
output "vpc_id" {
value = module.networking.vpc_id # Module output → root output
}
Data flow:
User (tfvars) → Root variables → Module inputs → Resources
↓
User ← Root outputs ← Module outputs ← Resource attributes
Best Practices
- Always add
description— it shows up interraform planand documentation - Always add
type— catches errors early - Use validation for constrained values (environments, CIDR blocks, instance types)
- Mark secrets
sensitive— prevents accidental exposure in logs - Group by file:
variables.tf,outputs.tf,locals.tf - Use objects for related values instead of many individual variables
# ❌ Too many individual variables
variable "db_engine" { ... }
variable "db_version" { ... }
variable "db_instance_class" { ... }
variable "db_multi_az" { ... }
# ✅ One object variable
variable "database" {
type = object({
engine = string
version = string
instance_class = string
multi_az = bool
})
}
Hands-On Courses
- Terraform for Beginners on CopyPasteLearn
- Terraform By Example — practical code examples
Conclusion
Variables are inputs (what goes into your config), outputs are results (what comes out). They form the interface of every Terraform module — variables define what the caller must provide, outputs define what the module exposes. Use types and validation to catch errors early, mark secrets sensitive, and use object types to group related inputs.