TerraformXIV · Modules: Reusable Building BlocksProduction Terraform
Designing a Module Interface
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
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:
| Type | When to use |
|---|---|
string | Single value (CIDR, name, ARN, region) |
number | Numeric value (port, count, size) |
bool | On/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:
-
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.
-
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.
-
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
-
anytyped variables. The consumer can pass anything. The contract is weak. The fix is to use a specific type. -
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.
-
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.
-
Missing
validationblocks. 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. -
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.
-
Variables that accept secrets in plain text. A
database_passwordvariable invariables.tfis a secret in the source. The fix is to mark the variablesensitive = trueand 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
validationblock that catches a malformed CIDR prevents the bad value from reaching the provider. - Do not expose secrets as outputs without
sensitive. An output markedsensitiveis 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 validatereturnsSuccess! The configuration is valid. -
terraform planruns 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.tfdeclarations.
Knowledge check · 7 questions
Q1. Which is the right default for a variable that controls whether S3 buckets are encrypted at rest?
Q2. When does a Terraform 1.9 validation block run?
Q3. Marking an output as sensitive = true encrypts the value in the state file.
Q4. A module exposes every internal resource attribute as an output. What is the problem?
Q5. Which of the following are characteristics of a good module interface? (Select all that apply.)
Q6. Why is using the type any for a variable almost always the wrong choice?
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.