Table of contents
- Where Reuse Begins
- variable — External Input
- Type Constraints
- validation — Value Validation
- sensitive — Sensitive Values
- Multiple Ways to Set Values and Their Precedence
- output — Exporting Values
- locals — Intermediate Calculations
- The Typical Pattern Combining All Three
- Common Mistakes
- What’s Next
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.
- variable: Receives values from outside the module
- output: Exposes values outside the module
- locals: Defines intermediate computation results inside the module
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.
description: A description of the variable. Used interraform planoutput and auto-generated documentationtype: Type constraint. An error is thrown if the wrong type is provideddefault: Default value. If not specified, the variable becomes required
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.
Recommended Pattern for Practice
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.
- Console output after apply
- Querying with
terraform output <name> - When using modules, for the parent module to receive values from child modules
# 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.
- variable: Input that comes from outside. Users can change the value
- locals: Derived values created inside. Users cannot touch them
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
- Not adding
sensitiveto sensitive values. They end up in logs and CI history. Default tosensitive = truefor anything secret-related - Overusing
defaultinvariables.tf. If you add a default to a required parameter, an empty string might slip through unnoticed. If it’s truly required, omit the default to force “you must provide this” - Declaring as
variablewhat should belocals. If it’s not user input, making it a variable exposes it unnecessarily. The reverse mistake is also common - Committing tfvars to Git. Always check whether sensitive values are inside. Add
*.tfvarsto.gitignoreby default, and explicitly add only the environment-specific files that truly need to be committed
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.


Loading comments...