
Variables, outputs, and locals are fundamental to Infrastructure as Code (IaC) with Terraform and OpenTofu. They enable you to:
This pillar consolidates essential knowledge about these mechanisms, covering input variables, output values, local values, and tfvars file management—everything you need to master variables and outputs in 2026.
Input variables are the parameters of your Terraform module. They allow you to pass external values into your configuration, making it reusable across different environments and scenarios. Think of them as function arguments in programming.
Variables are declared in .tf files (commonly variables.tf) using the variable block. Key arguments include:
**type**: Specifies the data type (string, number, bool, list, map, object, set, tuple, or any)description: Documents the variable's purpose (highly recommended for clarity)default: Provides a default value, making the variable optional**nullable**: When false, prevents null values (Terraform 1.1+)**sensitive**: When true, redacts the value from CLI output (but not from state files)validation: Defines custom rules for validating the variable's valuevariable "environment" {
type = string
description = "The environment name (dev, staging, prod)."
default = "dev"
}
variable "instance_count" {
type = number
description = "Number of EC2 instances to create."
default = 1
validation {
condition = var.instance_count > 0 && var.instance_count <= 100
error_message = "Instance count must be between 1 and 100."
}
}
variable "instance_type" {
type = string
description = "The EC2 instance type."
sensitive = false # Set to true to redact from CLI output
}
variable "tags" {
type = map(string)
description = "Common tags to apply to all resources."
default = {
ManagedBy = "Terraform"
}
}Terraform supports several variable types:
string, number, boollist(type), map(type), set(type)object({...}), tuple([...]) — for complex objects with optional fields, see optional attributes on complex input variablesany (use sparingly; reduces type safety)Example: Complex Type (Object)
variable "app_service" {
type = object({
name = string
instance_type = string
port = number
enabled = bool
})
description = "Configuration for the application service."
}Use the validation block to enforce business logic constraints:
variable "aws_region" {
type = string
description = "AWS region."
validation {
condition = contains(["us-east-1", "us-west-2", "eu-west-1"], var.aws_region)
error_message = "Region must be us-east-1, us-west-2, or eu-west-1."
}
}Mark variables as sensitive when they contain passwords, API keys, or other confidential data:
variable "database_password" {
type = string
description = "The database root password."
sensitive = true
}Important: sensitive = true redacts values from CLI output but does not encrypt them in the state file. Always secure your state file with encryption and backend access controls.
tfvars files (Terraform variables files) assign concrete values to input variables declared in your configuration. They separate configuration values from infrastructure code, enabling environment-specific deployments. For a quick syntax reference, see our Terraform & OpenTofu tfvars cheatsheet.
There are two syntax formats for tfvars files:
HCL Format (*.tfvars)
instance_type = "t2.medium"
aws_region = "us-east-1"
instance_count = 3
enable_monitoring = true
tags = {
Environment = "production"
Team = "platform"
}JSON Format (*.tfvars.json)
{
"instance_type": "t2.medium",
"aws_region": "us-east-1",
"instance_count": 3,
"enable_monitoring": true,
"tags": {
"Environment": "production",
"Team": "platform"
}
}List of Objects Example
Assume variables.tf defines:
variable "app_services" {
type = list(object({
name = string
instance_type = string
port = number
}))
}In services.tfvars:
app_services = [
{ name = "frontend", instance_type = "t3.medium", port = 80 },
{ name = "backend", instance_type = "t3.large", port = 8080 },
{ name = "api", instance_type = "t3.large", port = 3000 }
]Terraform automatically loads tfvars files from the root module directory in a specific order:
terraform.tfvars (HCL format) - if presentterraform.tfvars.json (JSON format) - if present*.auto.tfvars files in lexical (alphabetical) order*.auto.tfvars.json files in lexical orderThis automatic loading is convenient for default or environment-specific overrides. However, the scope is limited to the root module only—child modules cannot directly read values from tfvars files.
-var-fileTo load specific tfvars files, use the -var-file command-line option:
terraform plan -var-file="dev.tfvars"
terraform apply -var-file="prod.tfvars"
tofu apply -var-file="environments/staging.tfvars"Multiple -var-file options can be specified, and they are loaded in the order given:
terraform apply -var-file="base.tfvars" -var-file="prod.tfvars"A recommended directory structure for multi-environment management:
project/
├── main.tf
├── variables.tf
├── outputs.tf
├── terraform.tfvars.example # Commit this, not secrets
├── environments/
│ ├── dev.tfvars
│ ├── staging.tfvars
│ └── prod.tfvars
└── .gitignore # Exclude actual tfvars files
Corresponding .gitignore:
# Exclude actual tfvars files (may contain secrets)
*.tfvars
!*.tfvars.example
If a variable is specified in multiple places, Terraform applies values in order of increasing precedence (later values override earlier ones):
TF_VAR_name (or OPENTOFU_VAR_name for OpenTofu)terraform.tfvars file**terraform.tfvars.json** file (if both .tfvars and .tfvars.json exist, JSON overrides for the same variable)*.auto.tfvars or *.auto.tfvars.json files (loaded alphabetically; later files override earlier ones)-var and -var-file): Processed in the order given; highest precedenceOpenTofu introduces OPENTOFU_VAR_name environment variables while maintaining backward compatibility with TF_VAR_name:
TF_VAR_name is set: OpenTofu uses itOPENTOFU_VAR_name is set: OpenTofu uses itOPENTOFU_VAR_name takes precedenceFor new OpenTofu projects, adopt the OPENTOFU_VAR_ prefix.
Given this setup:
variables.tf:
variable "region" {
type = string
default = "us-west-2"
}terraform.tfvars:
region = "us-east-1"auto.tfvars:
region = "eu-west-1"Environment:
export TF_VAR_region="ap-south-1"Command:
terraform plan -var="region=ca-central-1"Result: The final value is ca-central-1 (command-line flag has highest precedence).
Use terraform console to inspect variable values:
terraform console
> var.region
"ca-central-1"Or examine the plan output:
terraform plan -var-file="dev.tfvars" | grep "region"Terraform outputs are named values that expose data from your infrastructure after terraform apply. They serve as the "return values" of your module, making critical information accessible to:
For step-by-step usage walkthroughs, see Terraform outputs: how-to with examples.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
output "vpc_id" {
value = aws_vpc.main.id
description = "The ID of the VPC."
}
output "public_subnet_id" {
value = aws_subnet.public.id
description = "The ID of the public subnet."
}After terraform apply, the outputs are displayed:
Outputs:
public_subnet_id = "subnet-0123456789abcdef"
vpc_id = "vpc-0123456789abcdef"
value (required)
The expression to expose as an output. Can be a resource attribute, local value, or computed expression.
output "s3_bucket_name" {
value = aws_s3_bucket.app_data.id
}description (optional, highly recommended)
Human-readable explanation of what the output represents.
output "database_endpoint" {
value = aws_db_instance.main.endpoint
description = "The endpoint address of the RDS database instance."
}sensitive (optional, default: false)
When true, the CLI redacts the output value from display (useful for passwords, API keys, etc.). Note: The value is still stored in the state file.
output "db_password" {
value = aws_db_instance.main.password
sensitive = true
description = "The database root password. Value is redacted from logs."
}depends_on (optional)
Explicitly declares dependencies on resources or modules. Rarely needed because Terraform infers dependencies from value references.
resource "aws_instance" "app" {
# ...
}
resource "aws_security_group_rule" "allow_ssh" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_instance.app.vpc_security_group_ids[0]
}
output "app_public_ip" {
value = aws_instance.app.public_ip
description = "The public IP of the application server."
depends_on = [aws_security_group_rule.allow_ssh]
}ephemeral (optional, Terraform 1.10+)
When true, prevents the output value from being persisted in the state file. Useful for temporary values only needed during a specific run (e.g., bootstrapping credentials).
resource "random_password" "temp_creds" {
length = 16
}
output "bootstrap_password" {
value = random_password.temp_creds.result
sensitive = true
ephemeral = true
description = "Temporary password for bootstrapping. Not stored in state."
}Outputs can return structured data:
output "instance_details" {
value = {
id = aws_instance.web.id
public_ip = aws_instance.web.public_ip
arn = aws_instance.web.arn
}
description = "Details of the web server instance."
}
output "subnet_ids" {
value = [aws_subnet.public.id, aws_subnet.private.id]
description = "List of subnet IDs."
}For expressions transform and restructure data before exposing it:
resource "aws_instance" "web" {
count = 3
ami = "ami-0abcdef1234567890"
instance_type = "t2.micro"
tags = {
Name = "web-server-${count.index + 1}"
}
}
output "web_server_public_ips" {
description = "A map of web server names to public IPs."
value = {
for instance in aws_instance.web :
instance.tags.Name => instance.public_ip
}
}Result:
web_server_public_ips = {
"web-server-1" = "192.0.2.101"
"web-server-2" = "192.0.2.102"
"web-server-3" = "192.0.2.103"
}
terraform outputThe terraform output command retrieves output values programmatically:
# Display all outputs
terraform output
# Get a specific output
terraform output vpc_id
# Get output as JSON
terraform output -json
terraform output -json | jq '.vpc_id.value'
# Get raw output (useful for scripts)
terraform output -raw vpc_idThis is essential for CI/CD integration:
#!/bin/bash
APP_URL=$(terraform output -raw app_url)
echo "Deploying to $APP_URL"Access outputs from child modules using the module. syntax:
module "network" {
source = "./modules/network"
# ...
}
output "vpc_id_from_module" {
value = module.network.vpc_id
}Use terraform_remote_state to reference outputs from other Terraform configurations:
Network Configuration (network/main.tf)
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_id" {
value = aws_subnet.public.id
}Application Configuration (application/main.tf)
data "terraform_remote_state" "network" {
backend = "s3" # Or "local", "azurerm", etc.
config = {
bucket = "my-terraform-state"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.public_subnet_id
}Local values (or "locals") are a way to assign a name to an expression within a module. They are internal to the module—not meant for external configuration—and serve to:
For more real-world scenarios where locals shine, see when should you use Terraform locals?.
Locals are defined in locals blocks (note the plural). A module can have multiple locals blocks, which Terraform merges.
locals {
# Simple assignments
project_prefix = "mycorp"
environment = "dev"
region = "us-east-1"
# Derived values (referencing other locals)
common_name_prefix = "${local.project_prefix}-${local.environment}"
# Complex structures
common_tags = {
Project = local.project_prefix
Environment = local.environment
ManagedBy = "Terraform"
CreatedAt = timestamp()
}
# Conditional logic
instance_type = local.environment == "prod" ? "t3.large" : "t2.medium"
}Access locals using the local.name syntax (note the singular "local"):
resource "aws_instance" "web" {
ami = "ami-0c55b31ad20599c04"
instance_type = local.instance_type # Use the conditional local
tags = merge(
local.common_tags,
{
Name = "${local.common_name_prefix}-web-server"
}
)
}
resource "aws_s3_bucket" "app_data" {
bucket = "${local.common_name_prefix}-data-storage"
tags = local.common_tags
}Consistent Naming Conventions
Enforce standardized names across resources:
locals {
app_name = "inventory"
env = "prod"
base = "${local.app_name}-${local.env}"
}
resource "aws_s3_bucket" "data" {
bucket = "${local.base}-data"
}
resource "aws_dynamodb_table" "logs" {
name = "${local.base}-logs"
}Common Tags
Apply consistent tags to all resources:
locals {
common_tags = {
Environment = var.environment
Project = var.project_name
Owner = "infra-team"
Terraform = "true"
}
}
resource "aws_instance" "web" {
# ...
tags = local.common_tags
}
resource "aws_s3_bucket" "assets" {
# ...
tags = local.common_tags
}Conditional Logic
Simplify conditional resource configuration:
locals {
# Determine instance type based on environment
instance_type = var.environment == "prod" ? "t3.xlarge" : "t2.medium"
# Build a list of AZs based on environment
availability_zones = var.environment == "prod" ? ["us-east-1a", "us-east-1b", "us-east-1c"] : ["us-east-1a"]
}
resource "aws_instance" "app" {
instance_type = local.instance_type
availability_zone = local.availability_zones[0]
}Complex Data Transformations
Pre-process data from variables:
variable "server_configs" {
type = map(object({
name = string
instance_type = string
enabled = bool
}))
}
locals {
# Filter to only enabled servers
enabled_servers = {
for name, config in var.server_configs :
name => config if config.enabled
}
# Create a list of server names (for iteration)
server_names = keys(local.enabled_servers)
}
resource "aws_instance" "servers" {
for_each = local.enabled_servers
instance_type = each.value.instance_type
tags = {
Name = each.value.name
}
}Understanding when to use locals versus input variables is crucial:
| Feature | Local Value | Input Variable | Output Value |
|---|---|---|---|
| Purpose | Internal naming, reduce repetition | Parameterize modules, accept external input | Expose module results, link modules |
| Scope | Internal to the module | Module's input API | Exports from module for external use |
| Assignment | Defined via expression within module | Set via CLI, env vars, tfvars, or calling module | Expression value, usually resource attributes |
| User Input | No direct external input; derived internally | Primary mechanism for external configuration | Not for input; displays/returns data |
| Analogy | Function's temporary local variables | Function arguments | Function return values |
| Reference | local.name |
var.name |
module.child.output_name or resource attributes |
❌ Incorrect: Locals cannot be set externally
# This doesn't work!
locals {
environment = "production" # Hardcoded—can't be changed per environment
}
# Or worse:
locals {
environment_prefix = substr(var.environment, 0, 1)
}
variable "resource_name" {
default = "${local.environment_prefix}-resource" # ERROR: Can't reference locals here
}✅ Correct: Use variables for external configuration
variable "environment" {
type = string
description = "The environment name."
}
variable "resource_name_prefix" {
type = string
description = "Prefix for resource names."
default = "resource"
}
locals {
environment_prefix = substr(var.environment, 0, 1)
resource_name = "${local.environment_prefix}-${var.resource_name_prefix}"
}
resource "aws_s3_bucket" "example" {
bucket = local.resource_name
}Tfvars files apply only to the root module. Child modules receive values through variable definitions:
Root Module (main.tf)
variable "instance_count" {
type = number
}
module "app" {
source = "./modules/app"
instance_count = var.instance_count # Pass root module variable to child
}Child Module (modules/app/main.tf)
variable "instance_count" {
type = number
}
resource "aws_instance" "web" {
count = var.instance_count
# ...
}terraform.tfvars only sets variables in the root module:
instance_count = 3 # This value flows to root, then to child moduleUse variable when:
Use local when:
for_eachLocals are powerful when combined with for_each for iteration:
variable "services" {
type = map(object({
port = number
enabled = bool
}))
}
locals {
# Filter to only enabled services
enabled_services = {
for name, config in var.services :
name => config if config.enabled
}
}
resource "aws_security_group_rule" "service_ingress" {
for_each = local.enabled_services
type = "ingress"
from_port = each.value.port
to_port = each.value.port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.main.id
}countWhen using count, reference specific elements explicitly:
resource "aws_instance" "web" {
count = 3
ami = "ami-0abcdef1234567890"
instance_type = "t2.micro"
tags = {
Name = "web-server-${count.index + 1}"
}
}
output "first_web_server_ip" {
value = aws_instance.web[0].public_ip # Explicitly index [0]
description = "The public IP of the first instance."
}
output "all_web_server_ips" {
value = [for instance in aws_instance.web : instance.public_ip]
description = "IPs of all web servers."
}for ExpressionsTransform resource collections into user-friendly outputs:
resource "aws_instance" "web" {
count = 3
ami = "ami-0abcdef1234567890"
tags = {
Name = "web-${count.index + 1}"
}
}
output "instance_summary" {
value = {
for i, instance in aws_instance.web :
"server-${i + 1}" => {
id = instance.id
ip = instance.public_ip
az = instance.availability_zone
}
}
description = "Summary of all instances with ID, IP, and AZ."
}Use locals to centralize complex conditions. If ternaries and conditional expressions are new to you, see understand conditional statements in Terraform in 5 minutes.
variable "environment" {
type = string
}
variable "enable_detailed_monitoring" {
type = bool
default = false
}
locals {
# Determine feature flags based on environment
is_production = var.environment == "prod"
# Enable detailed monitoring in production or if explicitly requested
monitoring_enabled = local.is_production || var.enable_detailed_monitoring
# Scale configuration based on environment
autoscaling_config = local.is_production ? {
min_capacity = 5
max_capacity = 20
} : {
min_capacity = 1
max_capacity = 5
}
}
resource "aws_autoscaling_group" "app" {
min_size = local.autoscaling_config.min_capacity
max_size = local.autoscaling_config.max_capacity
desired_capacity = local.autoscaling_config.min_capacity
}
resource "aws_cloudwatch_metric_alarm" "cpu" {
count = local.monitoring_enabled ? 1 : 0
alarm_name = "app-cpu-alarm"
comparison_operator = "GreaterThanThreshold"
threshold = local.is_production ? 70 : 80
}Always include detailed descriptions:
variable "instance_type" {
type = string
description = "The EC2 instance type. Examples: t2.micro, t3.small, m5.large."
default = "t2.micro"
}Use validation blocks to catch errors early. For org-wide enforcement of constraints across teams, layer validation blocks with Policy as Code:
variable "environment" {
type = string
description = "Environment name."
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "instance_count" {
type = number
validation {
condition = var.instance_count >= 1 && var.instance_count <= 100
error_message = "Instance count must be between 1 and 100."
}
}Never commit plain-text secrets to version control:
# ❌ Don't do this
# prod.tfvars
db_password = "SuperSecretPassword123!"
# ✅ Do this: Use secure secret management
# prod.tfvars
db_password = var.db_password_from_vault
# Inject via environment variable or CI/CD
export TF_VAR_db_password=$(vault kv get -field=password secret/prod/db)Alternatives for secret management:
Structure for scalability:
infrastructure/
├── variables.tf
├── outputs.tf
├── main.tf
├── terraform.tfvars.example
├── environments/
│ ├── dev.tfvars
│ ├── staging.tfvars
│ └── prod.tfvars
└── .gitignore
Commit example files (not actual ones with secrets):
terraform.tfvars.example:
# Example configuration for development
environment = "dev"
instance_count = 2
instance_type = "t2.micro"
enable_monitoring = false
# Uncomment and update as needed
# aws_region = "us-east-1"
# db_engine_version = "13.7"Use consistent, descriptive names:
# ✅ Good: Clear intent
variable "max_retries"
variable "enable_auto_recovery"
variable "db_backup_retention_days"
# ❌ Avoid: Ambiguous or cryptic
variable "max_retry"
variable "ar"
variable "backup"Don't output sensitive data unnecessarily:
# ❌ Risky: Exposes plaintext password
output "db_password" {
value = aws_db_instance.main.password
}
# ✅ Better: Mark as sensitive
output "db_password" {
value = aws_db_instance.main.password
sensitive = true
}
# ✅ Best: Output only the endpoint, not credentials
output "db_endpoint" {
value = aws_db_instance.main.endpoint
description = "Database endpoint for application connection."
}Avoid repeating the same expression:
# ❌ Repetitive
resource "aws_security_group" "web" {
name = "${var.project}-${var.environment}-web-sg"
tags = {
Name = "${var.project}-${var.environment}-web-sg"
}
}
resource "aws_s3_bucket" "logs" {
bucket = "${var.project}-${var.environment}-logs"
tags = {
Name = "${var.project}-${var.environment}-logs"
}
}
# ✅ Cleaner with locals
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
}
}
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web-sg"
tags = merge(local.common_tags, { Name = "${local.name_prefix}-web-sg" })
}
resource "aws_s3_bucket" "logs" {
bucket = "${local.name_prefix}-logs"
tags = merge(local.common_tags, { Name = "${local.name_prefix}-logs" })
}.gitignore for sensitive files:
# Terraform files
*.tfstate*
*.tfvars
!*.tfvars.example
*.auto.tfvars
# Secrets
.env
.env.local
secret/
vault/
Commit:
.tf files (configuration)*.tfvars.example (templates only).gitignoreDo NOT commit:
*.tfvars (unless example).tfstate files.terraform/ directoryUse object types for clarity:
# ✅ Clear structure
variable "database_config" {
type = object({
engine = string
version = string
instance_class = string
allocated_storage = number
})
}
# ❌ Too flexible; loses type safety
variable "database_config" {
type = any
}Scalr provides a unified approach to managing Terraform variables across multiple workspaces and environments. This simplifies variable handling at scale:
Scalr Variable Hierarchy:
Variables can be:
terraform.tfvars.jsonExample: Creating an account-level variable in Scalr
# Variable defined in Scalr UI or API
variable "region" {
type = string
description = "AWS region for all resources"
default = "us-east-1"
}In Scalr, you would set:
terraformregionus-east-1account (available to all workspaces)
Use terraform_remote_state to reference outputs from other Scalr workspaces. Make sure your remote state backends are properly configured first:
data "terraform_remote_state" "network" {
backend = "remote"
config = {
hostname = "your-scalr-instance.scalr.io"
organization = "your-environment"
workspaces = {
name = "network-prod"
}
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.public_subnet_id
# ...
}
output "app_instance_id" {
value = aws_instance.app.id
}Scalr can enforce variable naming conventions and structure validation. For example:
# Scalr policy to enforce variable naming
def check_variable_names(context):
variables = context.tf_vars
for var_name in variables:
if not var_name.islower():
return False, f"Variable '{var_name}' must be lowercase"
return True, "All variables follow naming convention"Mastering variables, outputs, and locals is essential for writing scalable, maintainable Terraform and OpenTofu configurations. Key takeaways:
for expressions and conditional logic enable sophisticated data transformationsBy following these patterns and best practices, you'll build flexible, secure, and maintainable infrastructure code that scales with your organization.
