Skip to content
ioob.dev
Go back

Terraform Part 4 — Variables and Outputs

· 6 min read
Terraform Series (4/15)
  1. Terraform Part 1 — What Is Terraform
  2. Terraform Part 2 — Installation and First Deploy
  3. Terraform Part 3 — HCL Syntax
  4. Terraform Part 4 — Variables and Outputs
  5. Terraform Part 5 — Providers
  6. Terraform Part 6 — Resources and Dependencies
  7. Terraform Part 7 — Data Sources and Import
  8. Terraform Part 8 — State Management
  9. Terraform Part 9 — Modules
  10. Terraform Part 10 — Loops and Conditionals
  11. Terraform Part 11 — Workspaces and Environment Separation
  12. Terraform Part 12 — Kubernetes and Helm Providers
  13. Terraform Part 13 — CI/CD Integration
  14. Terraform Part 14 — Testing and Policy
  15. Terraform Part 15 — Practical Patterns and Pitfalls
Table of contents

Table of contents

Where Reuse Begins

All the code so far has been fully hardcoded. Values like ap-northeast-2, t3.micro, and terraform-first-deploy were directly embedded in resources. Fine for labs, but in practice, you hit limits fast. What if you want to create the same setup for dev/staging/prod? What if you want to run it across different regions? Copying the entire codebase and changing values is a recipe for disaster.

The mechanisms that solve this problem are variables, outputs, and locals. The names sound simple, but their roles are clearly distinct.

This part walks through each of these one by one.

variable — External Input

The most frequently used block. By convention, a variables.tf file is created and all variables are placed there.

# variables.tf
variable "region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "ap-northeast-2"
}

variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
}

Let’s look at each element.

environment has no default, so a value must always be provided. If you apply without providing it, Terraform either prompts interactively or fails with an error in non-interactive environments.

To reference a variable, use the var. prefix.

provider "aws" {
  region = var.region
}

resource "aws_instance" "web" {
  # ...
  tags = {
    Environment = var.environment
  }
}

Type Constraints

HCL can somewhat get by with implicit typing like JavaScript, but the principle for variables is to specify types explicitly. Type constraints catch mistakes at compile time.

Basic Types

variable "name" {
  type = string
}

variable "count" {
  type = number
}

variable "enabled" {
  type = bool
}

Collection Types

# List (only same type allowed)
variable "availability_zones" {
  type    = list(string)
  default = ["ap-northeast-2a", "ap-northeast-2b"]
}

# Map (same type values only)
variable "instance_types" {
  type = map(string)
  default = {
    dev  = "t3.micro"
    prod = "t3.large"
  }
}

# Set
variable "allowed_ports" {
  type    = set(number)
  default = [80, 443]
}

Structural Types

There are also structural types that bundle multiple attributes. These are object and tuple.

# object — a struct with named attributes
variable "web_server" {
  type = object({
    ami           = string
    instance_type = string
    count         = number
    enable_logs   = bool
  })
  default = {
    ami           = "ami-xxx"
    instance_type = "t3.micro"
    count         = 2
    enable_logs   = true
  }
}

# tuple — an ordered collection of multiple types
variable "location" {
  type    = tuple([string, number, number])
  default = ["Seoul", 37.5665, 126.9780]
}

In practice, object is used far more often. It’s useful when you want to bundle multiple related values into a single unit. tuple is rarely used.

optional — Optional Attributes

You can make attributes inside an object optional (Terraform 1.3+).

variable "web_server" {
  type = object({
    ami           = string
    instance_type = string
    enable_logs   = optional(bool, false)   # Default false
    backup        = optional(string)        # Default null
  })
}

Use this for “attributes where the default is usually enough, but you occasionally want to override.” It greatly improves the user experience when creating modules.

any — Defer to Type Inference

variable "tags" {
  type    = any
  default = {}
}

Use any when you don’t want to enforce a type. Flexible but risky, so only use it when you truly can’t determine the type.

validation — Value Validation

Sometimes type alone isn’t enough. For example, “environment must be one of dev/staging/prod.” The validation block handles this.

variable "environment" {
  description = "Environment name"
  type        = string

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

variable "instance_type" {
  type = string

  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "Only t3 family instances are allowed for cost savings."
  }
}

condition must evaluate to true to pass. can() is a function that returns true “if execution completes without error,” often used with regex for validation. You can also nest multiple validation blocks to enforce multiple rules.

When rules are embedded in code at the team level, even accidental misconfigurations are caught at the plan stage. More efficient than having a reviewer catch them.

sensitive — Sensitive Values

Values like database passwords and API keys must not appear in plan/apply logs. Marking them with sensitive = true causes Terraform to mask the value as (sensitive value) in logs.

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

resource "aws_db_instance" "main" {
  # ...
  password = var.db_password
}

Any output containing this variable is automatically treated as sensitive too. However, note that the sensitive flag only masks values in logs — they are stored in plain text in the tfstate file. That’s why the State file must be stored in an encrypted remote backend (S3 with SSE, Terraform Cloud, etc.).

An even better approach is to avoid putting passwords directly in variables and read them from AWS Secrets Manager or SSM Parameter Store instead.

data "aws_secretsmanager_secret_version" "db" {
  secret_id = "myapp/db/password"
}

resource "aws_db_instance" "main" {
  password = data.aws_secretsmanager_secret_version.db.secret_string
}

This way, only the secret ARN remains in tfstate, and the actual value is managed on the AWS side.

Multiple Ways to Set Values and Their Precedence

There are several ways to set variable values. When the same variable is specified through multiple paths, precedence applies.

flowchart LR
    D[Default value<br/>default] -->|Lowest priority| E
    E[Environment variable<br/>TF_VAR_foo] -->|Overrides| F
    F[terraform.tfvars] --> G
    G[*.auto.tfvars<br/>Alphabetical order] --> H
    H[-var-file= flag] --> I
    I[-var flag<br/>Highest priority]

From lowest to highest precedence:

1. default (Lowest)

The default attribute in the variable block. Used when no other value is provided.

2. Environment Variables

Reads environment variables in the TF_VAR_<name> format.

export TF_VAR_region="us-east-1"
terraform apply

Useful for injecting sensitive values in CI pipelines.

3. terraform.tfvars or terraform.tfvars.json

If a file with this name exists in the same directory, it’s automatically loaded. By convention, project-specific defaults go here.

# terraform.tfvars
region      = "ap-northeast-2"
environment = "dev"

4. *.auto.tfvars

Any <name>.auto.tfvars files beyond terraform.tfvars are also automatically loaded. They’re read in alphabetical order, with later files overriding earlier ones.

# dev.auto.tfvars
instance_type = "t3.micro"

# prod.auto.tfvars  (this file is usually only on the prod deployment branch)
instance_type = "t3.large"

5. -var-file= Flag

When you want to explicitly specify a file, use the CLI option.

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

Convenient when managing environment-specific tfvars in a directory structure.

environments/
  dev.tfvars
  staging.tfvars
  prod.tfvars

6. -var Flag (Highest)

When you want to override a value as a one-off during a single run.

terraform apply -var="environment=staging" -var="instance_type=t3.small"

Useful in CI for temporarily changing tag values.

A typical setup for managing per-environment configurations in a single project:

terraform/
  main.tf
  variables.tf
  outputs.tf
  environments/
    dev.tfvars
    staging.tfvars
    prod.tfvars

When deploying, explicitly specify the environment-specific tfvars.

terraform workspace select dev
terraform apply -var-file="environments/dev.tfvars"

Keep sensitive values (API keys, etc.) out of tfvars and separate them into environment variables or Secrets Manager.

output — Exporting Values

The output block declares “I want to expose this value to the outside.” It’s used in three places.

# outputs.tf
output "instance_id" {
  description = "EC2 instance ID"
  value       = aws_instance.web.id
}

output "instance_public_ip" {
  description = "EC2 public IP"
  value       = aws_instance.web.public_ip
}

output "db_endpoint" {
  description = "RDS endpoint"
  value       = aws_db_instance.main.endpoint
  sensitive   = false
}

output "db_password" {
  description = "DB password (masked in logs)"
  value       = var.db_password
  sensitive   = true
}

Outputs with sensitive = true are masked as (sensitive value) in plan/apply logs. However, querying directly with terraform output <name> will show the value. It’s less about hiding and more about “preventing accidental exposure in logs.”

Outputs can also be extracted in JSON format.

terraform output -json > outputs.json

This format is valuable in CI pipelines when “you need to pass values generated after apply to the next step.”

locals — Intermediate Calculations

locals are intermediate variables used only within a module. Use them when the same value appears in multiple places, or when you want to compute a complex expression just once.

# locals.tf
locals {
  # Common tags
  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
    CostCenter  = "platform-team"
  }

  # Conditional calculations
  is_prod        = var.environment == "prod"
  instance_type  = local.is_prod ? "t3.large" : "t3.micro"
  backup_enabled = local.is_prod

  # Naming convention
  name_prefix = "${var.project_name}-${var.environment}"
}

Reference them with the local. prefix (not locals.).

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = local.instance_type

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

It’s easy to confuse variable and locals, but their roles are clearly different.

Don’t use both for the same purpose. “Parameters” are variables, “internal calculations” are locals.

The Typical Pattern Combining All Three

Let’s see how variables, outputs, and locals are woven together in practice. Imagine a small module that spins up EC2 per environment.

# variables.tf — external input
variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of dev, staging, or prod."
  }
}

variable "project_name" {
  type = string
}

# locals.tf — internal derived values
locals {
  is_prod = var.environment == "prod"

  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }

  instance_type = local.is_prod ? "t3.large" : "t3.micro"
  replicas      = local.is_prod ? 3 : 1
  name_prefix   = "${var.project_name}-${var.environment}"
}

# main.tf — resource definitions
resource "aws_instance" "web" {
  count         = local.replicas
  ami           = data.aws_ami.amazon_linux.id
  instance_type = local.instance_type

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-web-${count.index}"
  })
}

# outputs.tf — values to expose externally
output "instance_ids" {
  value = aws_instance.web[*].id
}

output "public_ips" {
  value = aws_instance.web[*].public_ip
}

A Terraform directory in a project generally maintains this shape. Variables are parameters, locals are shared calculations, resources are actual declarations, outputs are result exposure. When roles are clear, the code stays organized even when it grows 10x.

Common Mistakes

What’s Next

Once you can work with variables and outputs, Terraform code starts to breathe. In the next part, we’ll dive into providers — the gateway through which Terraform communicates with cloud APIs. We’ll cover proper version constraints in required_providers, managing multiple regions/accounts in a single project (alias), and the differences between official and community providers.


-> Part 5: Providers


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform Part 3 — HCL Syntax
Next Post
Terraform Part 5 — Providers