Skip to main content
Technology & EngineeringTerraform354 lines

Terraform Variables Outputs

Terraform variables, outputs, locals, type constraints, validation, and data flow patterns

Quick Summary18 lines
You are an expert in Terraform variables, outputs, and locals for infrastructure as code.

## Key Points

1. Default value in variable block
2. `terraform.tfvars` or `terraform.tfvars.json`
3. `*.auto.tfvars` or `*.auto.tfvars.json` (alphabetical order)
4. `-var-file` flag (in order specified)
5. `-var` flag (in order specified)
6. `TF_VAR_<name>` environment variable
- **Always set `description`** on every variable and output. It is the primary documentation.
- **Use specific types** instead of `any`. Type constraints catch errors at plan time.
- **Mark sensitive values** with `sensitive = true` on both variables and outputs.
- **Use `optional()` with defaults** for object attributes (Terraform 1.3+) to simplify caller configuration.
- **Validate early.** Put `validation` blocks on variables to give clear error messages before Terraform contacts any API.
- **Keep locals readable.** If a local expression is complex, break it into multiple locals with descriptive names.
skilldb get terraform-skills/Terraform Variables OutputsFull skill: 354 lines
Paste into your CLAUDE.md or agent config

Variables, Outputs, and Locals — Terraform

You are an expert in Terraform variables, outputs, and locals for infrastructure as code.

Overview

Variables, outputs, and locals are the data flow mechanisms in Terraform. Input variables parameterize configurations, outputs expose values for consumption by other configurations or users, and locals compute intermediate values to reduce repetition. Together, they define the interface and internal wiring of every module.

Core Concepts

Input Variables

variable "project" {
  description = "Project name, used for resource naming and tagging"
  type        = string
}

variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be one of: dev, staging, prod."
  }
}

variable "instance_count" {
  description = "Number of application instances"
  type        = number
  default     = 1

  validation {
    condition     = var.instance_count > 0 && var.instance_count <= 20
    error_message = "Instance count must be between 1 and 20."
  }
}

variable "enable_monitoring" {
  description = "Whether to enable detailed monitoring"
  type        = bool
  default     = false
}

variable "db_password" {
  description = "Database master password"
  type        = string
  sensitive   = true
}

Type Constraints

# Simple types
variable "name" { type = string }
variable "port" { type = number }
variable "enabled" { type = bool }

# Collection types
variable "availability_zones" {
  type = list(string)
}

variable "tags" {
  type    = map(string)
  default = {}
}

variable "cidr_blocks" {
  type = set(string)
}

# Structural types
variable "ingress_rules" {
  description = "List of ingress rules for the security group"
  type = list(object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
    description = optional(string, "Managed by Terraform")
  }))
  default = []
}

variable "notification_config" {
  description = "SNS notification configuration"
  type = object({
    topic_arn = string
    events    = set(string)
    filter    = optional(map(string), {})
  })
  default = null
}

# Tuple type (fixed length, mixed types)
variable "health_check" {
  type    = tuple([string, number, number])
  # Example: ["/health", 30, 5] — path, interval, threshold
}

# any type (use sparingly)
variable "extra_tags" {
  type    = any
  default = {}
}

Outputs

output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = [for s in aws_subnet.public : s.id]
}

output "db_endpoint" {
  description = "Database connection endpoint"
  value       = aws_db_instance.main.endpoint
}

output "db_password" {
  description = "Database master password"
  value       = aws_db_instance.main.password
  sensitive   = true
}

# Conditional output
output "nat_gateway_ip" {
  description = "NAT gateway public IP, if created"
  value       = var.create_nat_gateway ? aws_eip.nat[0].public_ip : null
}

Locals

Locals compute values from variables, data sources, and other locals to reduce repetition and improve readability.

locals {
  # Computed naming
  name_prefix = "${var.project}-${var.environment}"

  # Merged tags
  common_tags = merge(var.extra_tags, {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  })

  # Derived data
  private_subnets = { for az in var.availability_zones :
    az => cidrsubnet(var.vpc_cidr, 8, index(var.availability_zones, az))
  }

  public_subnets = { for az in var.availability_zones :
    az => cidrsubnet(var.vpc_cidr, 8, index(var.availability_zones, az) + length(var.availability_zones))
  }

  # Conditional logic
  is_production   = var.environment == "prod"
  backup_enabled  = local.is_production
  multi_az        = local.is_production
  deletion_protection = local.is_production
}

Implementation Patterns

Variable Precedence

Terraform evaluates variables in this order (last wins):

  1. Default value in variable block
  2. terraform.tfvars or terraform.tfvars.json
  3. *.auto.tfvars or *.auto.tfvars.json (alphabetical order)
  4. -var-file flag (in order specified)
  5. -var flag (in order specified)
  6. TF_VAR_<name> environment variable
# Using environment variables
export TF_VAR_db_password="supersecret"
terraform apply

# Using var-file and overrides
terraform apply \
  -var-file=environments/prod.tfvars \
  -var="instance_count=5"

Complex Validation

variable "cidr_block" {
  type = string

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "Must be a valid CIDR notation (e.g., 10.0.0.0/16)."
  }

  validation {
    condition     = tonumber(split("/", var.cidr_block)[1]) <= 24
    error_message = "CIDR prefix must be /24 or larger (smaller number)."
  }
}

variable "name" {
  type = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,28}[a-z0-9]$", var.name))
    error_message = "Name must be 4-30 characters, lowercase alphanumeric and hyphens, starting with a letter."
  }
}

Dynamic Security Group Rules from Variable

variable "ingress_rules" {
  type = list(object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
    description = optional(string, "Managed by Terraform")
  }))
  default = [
    {
      port        = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "HTTPS from anywhere"
    },
    {
      port        = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "HTTP from anywhere"
    }
  ]
}

resource "aws_security_group" "web" {
  name   = "${local.name_prefix}-web"
  vpc_id = var.vpc_id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
      description = ingress.value.description
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Transforming Data with Locals

variable "services" {
  type = map(object({
    port     = number
    cpu      = number
    memory   = number
    replicas = number
  }))
}

locals {
  # Flatten services into a list for use with for_each on related resources
  service_ports = { for name, svc in var.services :
    name => svc.port
  }

  # Create listener rules from services
  listener_rules = { for name, svc in var.services :
    name => {
      path     = "/${name}/*"
      port     = svc.port
      priority = index(keys(var.services), name) + 1
    }
  }

  # Total resource requirements
  total_cpu    = sum([for svc in var.services : svc.cpu * svc.replicas])
  total_memory = sum([for svc in var.services : svc.memory * svc.replicas])
}

Best Practices

  • Always set description on every variable and output. It is the primary documentation.
  • Use specific types instead of any. Type constraints catch errors at plan time.
  • Mark sensitive values with sensitive = true on both variables and outputs.
  • Use optional() with defaults for object attributes (Terraform 1.3+) to simplify caller configuration.
  • Validate early. Put validation blocks on variables to give clear error messages before Terraform contacts any API.
  • Keep locals readable. If a local expression is complex, break it into multiple locals with descriptive names.
  • Use terraform.tfvars for defaults and -var-file for environment overrides. Do not commit sensitive .tfvars files.
  • Prefer structured variables (objects and lists of objects) over flat variables when values are logically grouped.

Core Philosophy

Variables, outputs, and locals define the contract between a module and its consumers. A well-designed variable interface is the difference between a module that is a joy to use and one that requires reading every line of source code to understand. Think of variables as the public API of your infrastructure component: they should be minimal, well-documented, and stable across versions.

Type constraints and validation blocks are your first line of defense against misconfiguration. A variable that accepts any string when it should only accept "dev", "staging", or "prod" is a bug waiting to happen. By encoding constraints directly in the variable definition, you shift error detection from plan-time (or worse, apply-time) to the moment the caller provides the value. Clear error messages in validation blocks save hours of debugging.

Locals are the private helper functions of Terraform. They reduce repetition, give meaningful names to complex expressions, and centralize derived values so they can be changed in one place. However, locals can also become a maze of indirection if overused. A local that is referenced once and does nothing more than rename a variable adds noise without value. Use locals when they genuinely improve readability or eliminate duplication.

Anti-Patterns

  • Exposing every possible knob as a variable. A module with 40 variables is not flexible; it is unusable. Most callers want sensible defaults. Expose only the variables that genuinely vary across use cases and provide defaults for everything else.

  • Using any type as the default choice. The any type bypasses Terraform's type checking entirely, allowing callers to pass values that will fail at plan or apply time with cryptic errors. Use specific types and lean on optional() for object attributes that may not always be needed.

  • Sensitive values without sensitive = true. Forgetting to mark database passwords, API keys, or private keys as sensitive causes them to appear in plain text in CLI output, plan files, and CI logs. Always mark sensitive variables and any outputs derived from them.

  • Complex expressions inlined everywhere. Repeating "${var.project}-${var.environment}" in ten resource blocks instead of defining a local.name_prefix creates maintenance burden and inconsistency risk. If you use the same expression more than twice, it belongs in a local.

  • Relying on interactive prompts for required variables. Variables without defaults that are not supplied via .tfvars files, -var flags, or environment variables cause Terraform to prompt for input. In CI/CD, this hangs the pipeline indefinitely. Always supply all required variables explicitly.

Common Pitfalls

  • Using any type and losing validation. The any type bypasses Terraform's type checking. Use it only when the type genuinely varies.
  • Circular references in locals. Locals cannot reference themselves or create cycles with other locals.
  • Forgetting sensitive on outputs that derive from sensitive variables. Terraform will error if you pass a sensitive variable to a non-sensitive output.
  • Overusing variables. Not everything needs to be a variable. If a value will never change (like a protocol or a well-known port), use a local or a literal.
  • Variable default with null can be useful for optional resources, but forgetting to handle the null case in the configuration causes runtime errors.
  • Confusing set and list. Sets are unordered and deduplicated. If you need ordering, use list. If you use a list where a set is expected, Terraform converts it automatically, but duplicates are silently removed.
  • Not using .tfvars file and instead relying on interactive prompts. In CI/CD, interactive prompts cause the pipeline to hang. Always supply all required variables via files, flags, or environment variables.

Install this skill directly: skilldb add terraform-skills

Get CLI access →