Skip to main content
RunBook Academy

TerraformXIV · Modules: Reusable Building BlocksProduction Terraform

Designing a Module Interface

Intermediate⏱ ~16 minbash

What you'll learn

  • Design a module interface that is small, documented, and validated
  • Choose the right input variable types and defaults
  • Expose only the outputs the consumer needs
  • Use validation blocks to fail-fast on bad input

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

The module interface is the contract between the module and its consumers. The interface is the only thing the consumer sees. The interface is what the consumer references. The interface is the only thing the consumer can plausibly review. The implementation inside the module is the module author’s responsibility; the interface is shared.

A good interface is small, documented, and validated. A bad interface is large, undocumented, and permissive. The shape of the interface determines how the module is adopted and how the module ages.

The contract: variables and outputs

The interface is the variables and the outputs. Nothing else is part of the contract. The resources inside the module are implementation. The provider configuration is implementation. The data sources are implementation. The variables and outputs are the contract.

# modules/network/variables.tf

variable "vpc_cidr" {
  type        = string
  description = "The CIDR block for the VPC. Must be a valid IPv4 CIDR."
}

variable "environment" {
  type        = string
  description = "The environment name (dev, staging, prod)."

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

variable "availability_zones" {
  type        = list(string)
  description = "List of availability zones to span. Minimum 2, maximum 3."
  default     = ["us-east-1a", "us-east-1b", "us-east-1c"]

  validation {
    condition     = length(var.availability_zones) >= 2 && length(var.availability_zones) <= 3
    error_message = "Between 2 and 3 availability zones."
  }
}

The consumer sees three variables. The implementation is hidden. The contract is the three variables.

The right input variable types

Terraform 1.9 supports these primitive and complex types:

TypeWhen to use
stringSingle value (CIDR, name, ARN, region)
numberNumeric value (port, count, size)
boolOn/off toggle
list(T)Ordered collection of same type
set(T)Unordered collection, unique values
map(T)Keyed collection of same type
object({...})Structured value with named attributes
tuple([...])Ordered collection with mixed types

The right type is the most restrictive type that fits. bool is more restrictive than string — enable_flow_logs = "yes" is rejected. list(string) is more restrictive than any — the consumer cannot pass a list of objects by accident.

any is almost never the right type. If the variable accepts any, the consumer can pass anything, and the contract is the weakest possible. The only legitimate use of any is when the variable is forwarded to a provider argument that accepts arbitrary values.

The right defaults

A default is a contract. The default is what the consumer gets when the consumer does not specify a value. The default is the safe value, not the convenient value.

Three rules for defaults:

  1. Required variables have no default. A variable that is required is one the consumer must think about. The default would make the consumer forget to think.

  2. Optional variables have a safe default. The default is what the consumer gets if they do not think. The default must be the production-safe value, not the cheap value.

  3. Defaults are documented. The README’s default column is the documentation. The default is the contract.

variable "enable_flow_logs" {
  type        = bool
  description = "Whether to enable VPC flow logs to a central S3 bucket. Default true to satisfy the org-wide audit requirement."
  default     = true
}

The default of true is the safe default. The consumer who wants to disable flow logs must opt out, deliberately. The audit requirement is enforced by the default.

The discipline of few variables

The hard limit on a module’s variables is whatever the team’s review cycle can absorb. A module with forty variables is not reviewable. A module with four variables is reviewable. The practical ceiling is around ten variables for a module that is consumed by more than one team.

The discipline is to ask, for each variable: “Is this something the consumer should choose, or is this something the module should decide?” If the module should decide, do not expose it. If the consumer should choose, expose only the minimum set of choices.

A common violation is to expose every resource attribute as a variable:

# Bad: every attribute is a variable
variable "vpc_cidr" { ... }
variable "instance_tenancy" { ... }
variable "enable_dns_support" { ... }
variable "enable_dns_hostnames" { ... }
variable "tags" { ... }

The consumer does not need to choose instance_tenancy. The module author has decided. The consumer does not need to choose enable_dns_support. The module has decided. The consumer is exposed to internal decisions that should not be the consumer’s problem.

The fix is to delete the variable. The module picks a value. The consumer does not see the variable. The interface is smaller.

Outputs: only what the consumer needs

Outputs are the promises the module makes. The consumer depends on the outputs. The outputs are part of the public contract. Changing an output is a breaking change.

The rule is to expose only what the consumer needs to use the module. The consumer does not need to know the internal route table IDs. The consumer does not need to know the IAM role ARN. The consumer needs the VPC ID, the subnet IDs, and any address the consumer has to pass to another module.

# modules/network/outputs.tf

output "vpc_id" {
  description = "The ID of the VPC."
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "The public subnet IDs, keyed by availability zone."
  value       = { for az, subnet in aws_subnet.public : az => subnet.id }
}

output "private_subnet_ids" {
  description = "The private subnet IDs, keyed by availability zone."
  value       = { for az, subnet in aws_subnet.private : az => subnet.id }
}

The consumer gets the three values the consumer needs. The internal route table IDs, IAM role ARNs, and flow log group names are not exposed. The consumer cannot reach into the module’s internals.

Marking outputs as sensitive

Some outputs should not appear in the CLI output. A database password, an API token, a private key — anything the consumer might paste into a chat window. Terraform provides sensitive = true:

output "database_password" {
  description = "The initial password for the database master user."
  value       = aws_db_instance.main.password
  sensitive   = true
}

The output is suppressed in the CLI. The value is still used by other Terraform resources. The value is not visible in the state file without explicit effort. The sensitive flag is the contract that the value should not be casually displayed.

The flag is not a security control. The value is in the state file. The state file is the production control. The sensitive = true flag is a UX control.

Input validation with validation blocks

Terraform 1.9 supports validation blocks inside variable declarations. The validation runs at terraform plan time, before any provider is called. The consumer gets a clear error message at the point of input.

variable "vpc_cidr" {
  type        = string
  description = "The CIDR block for the VPC. Must be a valid IPv4 CIDR with a /16 or smaller prefix."

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))
    error_message = "vpc_cidr must be a valid CIDR block (e.g. 10.0.0.0/16)."
  }

  validation {
    condition     = tonumber(split("/", var.vpc_cidr)[1]) <= 16
    error_message = "vpc_cidr must use a /16 prefix or smaller (e.g. 10.0.0.0/16)."
  }
}

The validation catches the error at plan time. The consumer does not get a half-applied infrastructure. The consumer does not get a confusing provider error. The consumer gets a clear message at the point of input.

The validation runs against the variable’s value. The condition can reference other variables via var.<name>. The condition cannot reference resources or data sources. The validation is purely declarative.

A worked example

The minimal but production-realistic interface for a network module:

# modules/network/variables.tf

variable "vpc_cidr" {
  type        = string
  description = "The CIDR block for the VPC. Must be /16 or smaller."

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))
    error_message = "vpc_cidr must be a valid IPv4 CIDR (e.g. 10.0.0.0/16)."
  }
}

variable "environment" {
  type        = string
  description = "The environment name."

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

variable "availability_zones" {
  type        = list(string)
  description = "Availability zones to span."
  default     = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

variable "enable_flow_logs" {
  type        = bool
  description = "Enable VPC flow logs to the central logging bucket."
  default     = true
}

variable "tags" {
  type        = map(string)
  description = "Additional tags to apply to all resources."
  default     = {}
}
# modules/network/outputs.tf

output "vpc_id" {
  description = "The ID of the VPC."
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "Public subnet IDs, keyed by AZ."
  value       = { for az, subnet in aws_subnet.public : az => subnet.id }
}

output "private_subnet_ids" {
  description = "Private subnet IDs, keyed by AZ."
  value       = { for az, subnet in aws_subnet.private : az => subnet.id }
}

Five variables. Three outputs. Every variable is validated. Every output is documented. The consumer can adopt the module by reading the README.

Inspection commands

The reader validates the interface:

# Severity: READ-ONLY
terraform validate
Success! The configuration is valid.

The validation catches syntax errors and type errors. It does not run the validation blocks. The validation blocks run at plan time.

# Severity: READ-ONLY
terraform plan -input=false
Error: Invalid value for variable

  on variables.tf line 12:
  12:   environment = "qa"

Environment must be dev, staging, or prod.

The validation block runs. The error message is clear. The consumer knows what to fix.

Production failure modes

  1. any typed variables. The consumer can pass anything. The contract is weak. The fix is to use a specific type.

  2. Default values that allow the consumer to ignore the policy. A default that disables encryption makes the consumer’s life easy and the auditor’s life hard. The fix is to default to the safe value.

  3. Outputs that expose internal resource attributes. The consumer reaches into the module. Removing the output is a breaking change. The fix is to expose only what the consumer needs.

  4. Missing validation blocks. A bad input is rejected by the provider, deep in the apply, with a confusing error. The fix is to validate at the module boundary.

  5. Adding outputs after consumers have adopted the module. New outputs are fine. Removing outputs is a breaking change. The fix is to version the module under semver.

  6. Variables that accept secrets in plain text. A database_password variable in variables.tf is a secret in the source. The fix is to mark the variable sensitive = true and to pass the value from a secret store.

Security implications

  • Mark secret variables with sensitive = true. The CLI suppresses the value. The state file is still the source of truth.
  • Validate the input. A validation block that catches a malformed CIDR prevents the bad value from reaching the provider.
  • Do not expose secrets as outputs without sensitive. An output marked sensitive is still in the state file. The state file is encrypted at rest.
  • The interface is the boundary of trust. The module author can rely on the validation. The consumer can rely on the types.

Performance implications

  • Validation blocks run at plan time. The cost is negligible — a few function calls per variable.
  • A large number of variables slows the parse step. The cost is small (sub-millisecond per variable) but accumulates. The interface discipline is the right answer.
  • Outputs are computed at every plan. A large number of outputs adds compute time. The discipline is to expose only what the consumer needs.

What comes next

The next lesson is module sources: where the module lives, how the consumer references it, and the discipline of pinning.

Verification

  • terraform validate returns Success! The configuration is valid.
  • terraform plan runs the validation blocks and returns a clear error for a bad input.
  • Every variable has a description.
  • Every required variable has no default.
  • Every output has a description.
  • The total number of variables is fewer than ten.
  • The README’s inputs table matches the variables.tf declarations.

Knowledge check · 7 questions

  1. Q1. Which is the right default for a variable that controls whether S3 buckets are encrypted at rest?

  2. Q2. When does a Terraform 1.9 validation block run?

  3. Q3. Marking an output as sensitive = true encrypts the value in the state file.

  4. Q4. A module exposes every internal resource attribute as an output. What is the problem?

  5. Q5. Which of the following are characteristics of a good module interface? (Select all that apply.)

  6. Q6. Why is using the type any for a variable almost always the wrong choice?

  7. Q7. A consumer reports that the module's output database_endpoint is no longer present after a module upgrade. What is the right next step?

Passing score: 75%. Answers are checked in this browser.