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):
Default values in the variable declaration
terraform.tfvars (auto-loaded in the working directory)
*.auto.tfvars files (auto-loaded alphabetically)
-var-file=<path> flags passed to terraform plan or apply
-var="key=value" flags
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."
}
}
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.