Terraform Fundamentals

Variables, Outputs & Locals

22 min Lesson 4 of 30

Variables, Outputs & Locals

Hardcoding values directly into Terraform configuration is the fastest way to build infrastructure that cannot be reused, cannot be reviewed safely, and cannot be promoted across environments. At Google, Meta, and Stripe, every Terraform module in a platform engineering codebase treats values as a contract: inputs are declared as variables with types and validation, derived values are computed once as locals and referenced everywhere, and outputs expose data to parent modules and to automation tooling. Mastering these three constructs is what separates a throwaway script from a production-grade module.

Input Variables

An input variable is a typed, optionally-validated parameter that a module or root configuration accepts from outside. Declare them in variables.tf (convention, not a requirement). Every variable has a type, an optional default, a description for generated documentation, and an optional sensitive flag. Omitting default makes the variable required — Terraform will refuse to plan without a value.

# variables.tf variable "environment" { description = "Deployment environment: dev, staging, or production." type = string validation { condition = contains(["dev", "staging", "production"], var.environment) error_message = "environment must be one of: dev, staging, production." } } variable "instance_type" { description = "EC2 instance type for the web tier." type = string default = "t3.micro" } variable "replica_count" { description = "Number of web-tier instances (1-10)." type = number default = 2 validation { condition = var.replica_count >= 1 && var.replica_count <= 10 error_message = "replica_count must be between 1 and 10 inclusive." } } variable "allowed_cidr_blocks" { description = "List of CIDR blocks permitted inbound on port 443." type = list(string) default = ["10.0.0.0/8"] } variable "tags" { description = "Map of tags applied to every resource in this module." type = map(string) default = {} } variable "db_password" { description = "RDS master password — supply via TF_VAR or a secrets backend." type = string sensitive = true # value is redacted in plan output and state display }
Type system: Terraform supports string, number, bool, list(T), set(T), map(T), object({...}), and tuple([...]). Always declare explicit types. When you declare type = any, you lose all validation and auto-complete — it is only acceptable in thin wrapper modules that intentionally pass through opaque data.

Supplying Values: tfvars Files

Values flow into variables through several mechanisms, applied in this precedence order (later overrides earlier):

  1. Default values in the variable declaration
  2. terraform.tfvars (auto-loaded in the working directory)
  3. *.auto.tfvars files (auto-loaded alphabetically)
  4. -var-file=<path> flags passed to terraform plan or apply
  5. -var="key=value" flags
  6. Environment variables prefixed with TF_VAR_ (e.g., TF_VAR_db_password)

The standard big-tech pattern is one .tfvars file per environment, stored in the repo and selected via CI pipeline:

# envs/production.tfvars environment = "production" instance_type = "m6i.2xlarge" replica_count = 6 allowed_cidr_blocks = ["10.0.0.0/8", "192.168.0.0/16"] tags = { team = "platform" cost-center = "infra-prod" managed-by = "terraform" } # db_password is NOT here — it is injected by the CI system: # export TF_VAR_db_password="$(vault kv get -field=password secret/rds/prod)" # --- # envs/dev.tfvars environment = "dev" instance_type = "t3.small" replica_count = 1 allowed_cidr_blocks = ["10.10.0.0/16"] tags = { team = "platform" cost-center = "infra-dev" managed-by = "terraform" } # CI invocation: # terraform plan -var-file=envs/production.tfvars
Production pitfall — secrets in tfvars: Never commit passwords, API keys, or tokens into any .tfvars file, even a private repo. State files store these values in plaintext, and .tfvars files end up in git history. The correct pattern is: sensitive variables have no default, and the CI pipeline injects them via TF_VAR_ environment variables pulled from a secrets manager (HashiCorp Vault, AWS Secrets Manager, or GitHub Actions secrets). Pair this with remote state encryption.

Local Values

A local value (declared in a locals block) is an expression that is evaluated once and referenced throughout the module with the local. prefix. Locals exist for one reason: DRY (Don't Repeat Yourself). If you compute "${var.environment}-${var.project_name}" in twelve resource name fields, a one-character typo breaks your naming convention silently. Compute it once as a local, reference it everywhere.

# locals.tf locals { # Canonical name prefix used in every resource name name_prefix = "${var.environment}-${var.project_name}" # Merged tags: module-level defaults + caller-supplied overrides common_tags = merge( { Environment = var.environment ManagedBy = "terraform" Module = "web-tier" }, var.tags ) # CIDR breakdown — compute once, reference in SGs and route tables vpc_cidr = "10.${var.environment == "production" ? "0" : "1"}.0.0/16" # Boolean flag: production gets multi-AZ, others get single is_production = var.environment == "production" az_count = local.is_production ? 3 : 1 } # Usage example: resource "aws_instance" "web" { count = var.replica_count ami = data.aws_ami.amazon_linux.id instance_type = var.instance_type tags = merge(local.common_tags, { Name = "${local.name_prefix}-web-${count.index + 1}" Role = "web" }) }
Pro practice — locals as a documentation layer: Use locals to give names to complex expressions even when they are only referenced once. local.is_production reads like English and self-documents intent; var.environment == "production" scattered across twenty conditional expressions is harder to audit during a security review. Platform teams at Amazon and Cloudflare routinely use locals as a configuration-layer API surface: callers set variables, the module computes locals, and resources only reference locals.

Output Values

An output value publishes data from a module to its caller, or from a root module to the operator and to automation. Outputs serve three concrete purposes: they let parent modules reference child module resources (the VPC module outputs the VPC ID that the compute module needs), they feed CI pipeline steps (a pipeline reads the load balancer DNS name to run smoke tests), and they surface useful information after terraform apply.

# outputs.tf output "web_instance_ids" { description = "List of EC2 instance IDs in the web tier." value = aws_instance.web[*].id } output "load_balancer_dns" { description = "Public DNS name of the Application Load Balancer." value = aws_lb.web.dns_name } output "vpc_id" { description = "ID of the VPC created by this module." value = aws_vpc.main.id } output "db_endpoint" { description = "RDS instance endpoint (host:port)." value = "${aws_db_instance.main.address}:${aws_db_instance.main.port}" sensitive = true # redacted in console; still readable by callers and state }

In a parent module or root configuration, reference a child module output via module.<name>.<output_name>:

# In the root module or a parent module: module "network" { source = "./modules/network" environment = var.environment cidr_block = "10.0.0.0/16" } module "compute" { source = "./modules/compute" vpc_id = module.network.vpc_id # <-- module output reference subnet_ids = module.network.private_subnet_ids } # Read an output after apply: # terraform output load_balancer_dns # terraform output -json # all outputs as JSON (useful in CI) # terraform output -raw load_balancer_dns # bare string, no quotes

Variable Validation: Catching Mistakes Before Plan

Terraform's validation block runs before any API calls, rejecting misconfigured inputs immediately. This is the infrastructure equivalent of input validation in application code. In enterprise repos, a module without validation on its critical variables is a quality gate failure. Rules of thumb: validate environment names (an unconstrained string can create a resource named "prod " with a trailing space — real incident), validate CIDR format, validate that port numbers are in range, and validate that required object keys are non-empty.

variable "cidr_block" { type = string description = "VPC CIDR block — must be a valid /16 to /24." validation { condition = can(cidrhost(var.cidr_block, 0)) error_message = "cidr_block must be a valid CIDR notation (e.g. 10.0.0.0/16)." } } variable "port" { type = number description = "Application port (1024-65535)." validation { condition = var.port >= 1024 && var.port <= 65535 error_message = "port must be between 1024 and 65535." } } variable "db_engine" { type = object({ engine = string engine_version = string }) validation { condition = contains(["postgres", "mysql", "aurora-postgresql"], var.db_engine.engine) error_message = "db_engine.engine must be postgres, mysql, or aurora-postgresql." } }
Terraform Variables, Locals, and Outputs Data Flow Variable Data Flow: tfvars → Variables → Locals → Resources → Outputs Value Sources production.tfvars TF_VAR_* env vars -var flags Defaults Validated ✓ variables.tf var.environment var.instance_type var.replica_count var.db_password var.tags locals.tf local.name_prefix local.common_tags local.is_production local.az_count local.vpc_cidr Resources & Outputs aws_instance.web aws_lb.web aws_vpc.main output: vpc_id output: load_balancer_dns Validation runs before Plan. Sensitive values are redacted in output. Locals are computed once, referenced N times.
Data flow from value sources through variables and locals to resources, with outputs surfacing key data to callers and CI pipelines.

Putting It Together: A Real Module Interface

The combination of variables + validation + locals + outputs defines the public API of a module. A well-designed module has a narrow, typed interface: callers cannot supply garbage, internals are hidden, and the outputs expose exactly what consumers need. This is the pattern behind every Terraform module in the Terraform Registry and in the internal module repositories at Netflix, Shopify, and Datadog.

# In CI, read the ALB DNS name after apply and smoke-test it: LB_DNS=$(terraform output -raw load_balancer_dns) curl -sf "https://${LB_DNS}/health" || { echo "Smoke test failed"; exit 1; } # In a parent module, pass the output into another module: module "dns" { source = "./modules/route53" alb_dns_name = module.compute.load_balancer_dns hosted_zone_id = var.hosted_zone_id record_name = "${var.environment}.example.com" } # Inspect a sensitive output without printing to terminal: terraform output -json | jq -r '.db_endpoint.value' # (this still prints the plaintext value — pipe it to your secrets tool)
Module design rule: Outputs should be stable identifiers (IDs, ARNs, DNS names) — not derived strings that callers could compute themselves. If a caller needs arn:aws:s3:::${module.storage.bucket_name}, expose the ARN directly as an output, not just the bucket name. This decouples callers from knowing how the ARN is constructed and lets the module change its internal naming without breaking callers.