Terraform

Variables, Outputs & Locals

● Beginner ⏱ 15 min read terraform

Hard-coding values into Terraform resources — environment names, region strings, instance sizes — makes configurations brittle and impossible to reuse. Variables, outputs, and locals are Terraform's three mechanisms for parameterizing configurations, exposing results, and computing reusable values. Together they turn a single-purpose script into a flexible, team-shareable module.

Input Variables

An input variable declares a parameter for a configuration. It is the Terraform equivalent of a function argument — callers supply values, the configuration uses them without caring where they come from.

variable "environment" {
  description = "Deployment environment (dev, staging, prod)"
  type        = string
  default     = "dev"
}

variable "instance_count" {
  description = "Number of EC2 instances to create"
  type        = number
  default     = 1
}

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

Reference a variable anywhere in your configuration with var.<NAME>:

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
  count         = var.instance_count

  monitoring = var.enable_monitoring

  tags = {
    Environment = var.environment
    Name        = "web-${var.environment}"
  }
}

Variables declared without a default are required — Terraform will prompt for their values if not supplied through another method.

Variable Types

Terraform has a rich type system for variables. Declaring types catches mismatches early — before an apply fails mid-way through creating resources.

Primitive types

variable "region" {
  type    = string
  default = "us-east-1"
}

variable "replicas" {
  type    = number
  default = 3
}

variable "multi_az" {
  type    = bool
  default = true
}

Collection types

# list — ordered sequence of values, all the same type
variable "availability_zones" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

# map — key/value pairs, all values the same type
variable "tags" {
  type = map(string)
  default = {
    Team    = "platform"
    Project = "learniac"
  }
}

# set — unordered unique values
variable "allowed_cidr_blocks" {
  type    = set(string)
  default = ["10.0.0.0/8", "172.16.0.0/12"]
}

Structural types

# object — named attributes with potentially different types
variable "database" {
  type = object({
    engine         = string
    instance_class = string
    multi_az       = bool
    storage_gb     = number
  })
  default = {
    engine         = "postgres"
    instance_class = "db.t3.micro"
    multi_az       = false
    storage_gb     = 20
  }
}

# Access object attributes with dot notation
resource "aws_db_instance" "main" {
  engine         = var.database.engine
  instance_class = var.database.instance_class
  multi_az       = var.database.multi_az
  allocated_storage = var.database.storage_gb
}

# tuple — ordered sequence with mixed types
variable "subnets" {
  type    = tuple([string, string, number])
  default = ["10.0.1.0/24", "us-east-1a", 1]
}
🧭
Use any sparingly

type = any disables type checking for that variable. It can be useful for module interfaces that need to accept heterogeneous values, but in general you should declare the most specific type possible — it makes errors much easier to diagnose.

Setting Variables

Terraform accepts variable values through several mechanisms, evaluated in this order (later sources override earlier ones):

1. Default values

Defined in the variable block. The fallback when nothing else is provided.

2. Environment variables

Prefix any variable name with TF_VAR_:

export TF_VAR_environment="prod"
export TF_VAR_instance_count="5"
terraform apply

This is the standard pattern for CI/CD pipelines — set secrets as environment variables in your CI system rather than writing them to files.

3. .tfvars files (automatically loaded)

Terraform automatically loads terraform.tfvars and any file ending in .auto.tfvars:

# terraform.tfvars
environment    = "prod"
instance_count = 5
enable_monitoring = true

tags = {
  Team    = "platform"
  Project = "learniac"
}

Use separate .tfvars files per environment:

# dev.tfvars
environment    = "dev"
instance_count = 1

# prod.tfvars
environment    = "prod"
instance_count = 10
enable_monitoring = true

4. -var-file flag

Explicitly load a .tfvars file:

terraform apply -var-file="prod.tfvars"

5. -var flag

Set individual values on the command line:

terraform apply -var="environment=prod" -var="instance_count=5"

6. Interactive prompt

If a required variable (no default) is not set by any other method, Terraform prompts for it. Avoid relying on this in CI/CD — unprovided required variables should fail fast with a clear error.

⚠️
Never commit .tfvars files that contain secrets

It is fine to commit .tfvars files with non-sensitive configuration (instance sizes, region names). Files containing API keys, passwords, or tokens should be in .gitignore. Use your CI/CD system's secret store (GitHub Secrets, GitLab CI variables) and inject values via TF_VAR_ environment variables instead.

Variable Validation

Validation rules catch invalid values before Terraform calls any provider APIs — giving users clear error messages rather than cryptic API errors:

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

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

variable "instance_type" {
  type        = string
  description = "EC2 instance type"

  validation {
    condition     = can(regex("^t[23]\\.", var.instance_type))
    error_message = "Instance type must be a t2 or t3 class (e.g., t3.micro)."
  }
}

variable "cidr_block" {
  type        = string
  description = "VPC CIDR block"

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

A variable can have multiple validation blocks. All conditions must be true or Terraform reports the corresponding error_message.

Sensitive Variables

Mark a variable as sensitive = true to prevent Terraform from printing its value in plan/apply output or in error messages:

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

variable "api_key" {
  type      = string
  sensitive = true
}
# CLI output redacts sensitive values:
# var.db_password
#   (sensitive value)
💡
Sensitive ≠ encrypted in state

Marking a variable sensitive prevents it from appearing in CLI output, but Terraform still stores its value in the state file in plaintext. Protect the state file with encryption and access controls — sensitive variable values there are not masked.

Output Values

Output values expose information from a configuration — to the terminal after an apply, to other Terraform configurations via remote state data sources, or to calling modules.

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

output "load_balancer_dns" {
  description = "DNS name of the application load balancer"
  value       = aws_lb.main.dns_name
}

output "db_endpoint" {
  description = "Database connection endpoint"
  value       = aws_db_instance.main.endpoint
  sensitive   = true   # Hides from CLI output; still in state
}

After an apply, Terraform prints all non-sensitive outputs:

Outputs:

load_balancer_dns = "my-app-lb-1234567890.us-east-1.elb.amazonaws.com"
vpc_id = "vpc-0abc123"

Access outputs at any time with:

# Show all outputs
terraform output

# Show a specific output value (useful in scripts)
terraform output -raw load_balancer_dns

# Show outputs as JSON
terraform output -json

Reading outputs from another configuration

Use terraform_remote_state to read outputs from a separate configuration's state:

data "terraform_remote_state" "network" {
  backend = "s3"
  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.private_subnet_id
  # ...
}

Local Values

Local values (locals) are named expressions computed once and reused throughout a configuration. Think of them as constants or intermediate computed values — like named variables in a programming language, but scoped to the configuration.

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

  # Common tags applied to all resources
  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
    CreatedAt   = "2026"
  }

  # Derived values
  is_prod         = var.environment == "prod"
  instance_type   = local.is_prod ? "t3.large" : "t3.micro"
  min_capacity    = local.is_prod ? 3 : 1
}

Reference locals with local.<NAME> (no trailing s):

resource "aws_instance" "web" {
  instance_type = local.instance_type
  tags          = merge(local.common_tags, {
    Name = "${local.name_prefix}-web"
  })
}

resource "aws_autoscaling_group" "web" {
  min_size = local.min_capacity
  # ...
}
🧭
When to use locals vs variables

Use variable for values that callers (users or parent modules) should be able to override. Use local for values that are always computed from other values and should never be overridden externally. A common pattern: accept raw inputs as variables, derive computed values as locals, and use locals in resources.

File Organization

By convention, split your configuration into these files:

FileContains
main.tfResources (the core of the configuration)
variables.tfAll variable blocks
outputs.tfAll output blocks
locals.tfAll locals blocks (or inline in main.tf)
providers.tfThe terraform {} block and all provider {} blocks
terraform.tfvarsVariable values for the default environment (do not commit secrets)
prod.tfvarsVariable values for production (committed if no secrets)

Here is what a minimal real-world configuration looks like with this layout:

# variables.tf
variable "project" {
  type    = string
  default = "learniac"
}

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "prod"], var.environment)
    error_message = "Must be dev or prod."
  }
}

variable "region" {
  type    = string
  default = "us-east-1"
}

# locals.tf
locals {
  name_prefix = "${var.project}-${var.environment}"
  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

# outputs.tf
output "bucket_name" {
  value       = aws_s3_bucket.data.bucket
  description = "Name of the S3 data bucket"
}

# terraform.tfvars (committed)
project     = "learniac"
region      = "us-east-1"

# dev.tfvars
environment = "dev"

# prod.tfvars
environment = "prod"

Deploy to different environments by selecting the right .tfvars file:

terraform apply -var-file="dev.tfvars"
terraform apply -var-file="prod.tfvars"

Key Takeaways