Skip to main content

Variables, Outputs, Locals -- Structuring a Module

What This Concept Is

Three value-carrying blocks that give a Terraform module its contract and its internal organization.

  • variable -- an input. Someone calling the module passes this in. Goes in variables.tf.
  • output -- a value the module exposes to its caller (or to the CLI, for root modules). Goes in outputs.tf.
  • locals -- named intermediate expressions reused inside the module. Goes anywhere but is often at the top of main.tf or in locals.tf.

Variables are the module's public API. Outputs are the module's return values. Locals are its private helpers.

Why It Matters Here

A module with a tight, named, validated input/output contract is reusable. A module whose main.tf is a mess of hard-coded strings and magic expressions is a snowflake nobody will touch.

You are also learning to recognize the cost of each type of value:

  • variables with no default = required input, louder failure
  • variables with default = optional input, invisible defaults
  • locals = computed once, no caller intervention, good for naming conventions
  • outputs = anything downstream stacks or humans need

Concrete Example

A tidy variables.tf:

variable "env" {
description = "Environment name, used in resource naming and tagging."
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "env must be one of: dev, staging, prod."
}
}

variable "instance_count" {
description = "Number of web instances."
type = number
default = 2
}

variable "tags" {
description = "Extra tags merged onto every resource."
type = map(string)
default = {}
}

The matching locals.tf:

locals {
name_prefix = "acme-${var.env}"
base_tags = merge(
{
Env = var.env
ManagedBy = "terraform"
Module = "web"
},
var.tags,
)
}

And a minimal outputs.tf:

output "instance_ids" {
description = "IDs of web instances (for downstream stacks)."
value = aws_instance.web[*].id
}

output "load_balancer_dns" {
description = "DNS name of the ALB in front of the web instances."
value = aws_lb.web.dns_name
sensitive = false
}

Three conventions in play:

  1. Every variable has description and type. Untyped variables are lazy; they bite reviewers six months later.
  2. locals.base_tags is the single source of truth for tags -- every resource references local.base_tags, not a hand-written map.
  3. Outputs describe what downstream code will consume. If an output's description reads "the thing," rewrite it.

A Second Example: Validation Pulling Its Weight

variable "instance_type" {
type = string
description = "EC2 instance type."
default = "t3.small"
validation {
condition = can(regex("^t3\\.(nano|micro|small|medium|large)$", var.instance_type))
error_message = "Only t3 sizes from nano to large are allowed in this module."
}
}

This kicks the caller as soon as they pass m5.24xlarge, not at apply time after three minutes of AWS dependency resolution. Validation blocks are one of the highest ROI features in HCL; use them.

Common Confusion / Misconception

"I should expose everything as a variable." Noisy modules with 40+ inputs are worse than opinionated modules with 8. Pick sane defaults and force callers to name the few things they really care about.

"Locals are like variables." They are not. locals cannot be overridden by a caller. They are pure expressions. If you want "same value but overridable," use a variable with a default.

"Outputs are optional." In a library module, yes. In a root module, outputs are often the only way humans discover IDs and DNS names after apply. Treat them as documentation.

"I can reference a local from another module." You cannot. Locals are module-scoped. If downstream needs the value, it has to be an output.

How To Use It

  1. Each module has three files minimum: main.tf, variables.tf, outputs.tf. Add locals.tf or providers.tf as needed.
  2. Every variable has description, type, and (if it makes sense) validation.
  3. Use locals to enforce naming and tagging conventions across all resources in the module.
  4. Use sensitive = true on outputs and variables that contain secrets. Terraform will redact them from CLI output (but not from state; see Cluster 1 Concept 02).
  5. Keep the variable list ordered "required first, then commonly-tuned, then rarely-tuned." Reviewers read top to bottom.

Check Yourself

  1. When would you choose a locals expression over a variable with a default?
  2. Why might you mark an output sensitive?
  3. What signals, on first glance, that a module was written with no contract discipline?

Mini Drill or Application

Take any resource you wrote in Concept 04 and wrap it in a mini-module:

  • define 2-4 variables with types, descriptions, and one validation block
  • define 2 locals (e.g., a name_prefix and a base_tags) and use them in the resource
  • define 1-2 outputs, each with a description
  • call the module from a parent main.tf and verify terraform apply still works

If you catch yourself writing tags = { Env = "prod" } literally in more than one place, you are overdue for a local.

See also (external)


Source Backbone

Infrastructure-as-code details are tool-specific, but these local books provide the operational backbone for shell, Git, and change discipline.