Module Inputs & Outputs
A module’s inputs and outputs are its public contract. Everything else — the resources, the locals, the wiring — is implementation detail that callers should never need to think about. A well-designed interface makes a module obvious to use, hard to misuse, and safe to refactor. This page covers how to choose required versus optional inputs, type and validate them rigorously, and expose outputs that callers actually need without leaking internals.
Inputs are the module’s API
Inputs are declared with variable blocks. Treat each one as a deliberate API decision: every variable you expose is a promise you have to keep, and every input you omit is flexibility you remove. Aim for a small, clear set of inputs that describe what the caller wants, not how the module achieves it.
A variable becomes required when it has no default, and optional when it does. Choose required inputs only for values the module genuinely cannot guess — names, identifiers, and environment-specific references. Give everything else a sensible default so the common case is a tiny call.
# modules/s3-bucket/variables.tf
variable "bucket_name" {
description = "Globally unique name for the S3 bucket."
type = string
# No default => required.
}
variable "versioning_enabled" {
description = "Whether to keep multiple versions of each object."
type = bool
default = true
}
variable "force_destroy" {
description = "Allow Terraform to delete a non-empty bucket on destroy."
type = bool
default = false
}
variable "tags" {
description = "Tags applied to every resource the module creates."
type = map(string)
default = {}
}
With those defaults in place, a caller can adopt the module in two lines while still overriding anything that matters:
module "logs" {
source = "../modules/s3-bucket"
bucket_name = "acme-prod-logs"
}
Type and validate everything
Always set an explicit type. Untyped variables default to any, which silently accepts garbage and pushes the error deep into a resource block where the message is cryptic. Rich types — object, list, set, map — let you model structured input precisely, and optional() attributes (Terraform 1.3+, also in OpenTofu) give object fields their own defaults.
variable "lifecycle_rules" {
description = "Object expiration rules to apply to the bucket."
type = list(object({
id = string
prefix = optional(string, "")
expiration_days = optional(number, 365)
enabled = optional(bool, true)
}))
default = []
}
Add validation blocks to reject bad input early with a message the caller can act on. This is far better than a provider rejecting the value at apply time.
variable "bucket_name" {
description = "Globally unique name for the S3 bucket."
type = string
validation {
condition = can(regex("^[a-z0-9.-]{3,63}$", var.bucket_name))
error_message = "bucket_name must be 3-63 chars: lowercase letters, digits, dots, hyphens."
}
}
variable "environment" {
description = "Deployment environment."
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
A failed validation surfaces immediately during plan:
Output:
╷
│ Error: Invalid value for variable
│
│ on main.tf line 3, in module "logs":
│ 3: environment = "production"
│
│ environment must be one of: dev, staging, prod.
╵
Mark secrets with sensitive = true so they are redacted from plan output and logs.
variable "kms_key_arn" {
description = "Customer-managed KMS key used to encrypt objects."
type = string
default = null
sensitive = true
}
Outputs: expose intent, not internals
Outputs let callers consume what the module built. The discipline mirrors inputs: export the handful of attributes a consumer realistically needs — IDs, ARNs, endpoints, names — and nothing more. Exporting every internal resource attribute couples callers to your implementation and makes refactoring a breaking change.
# modules/s3-bucket/outputs.tf
output "bucket_id" {
description = "Name of the created bucket."
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "ARN of the created bucket, for IAM policies."
value = aws_s3_bucket.this.arn
}
output "regional_domain_name" {
description = "Region-specific domain name for object access."
value = aws_s3_bucket.this.bucket_regional_domain_name
}
Propagate the sensitive flag on outputs that carry secret-derived values, otherwise Terraform errors when a sensitive value flows into a non-sensitive output:
output "access_key_secret" {
description = "Generated secret access key for the bucket user."
value = aws_iam_access_key.this.secret
sensitive = true
}
Required vs optional at a glance
| Input style | Declaration | Use when |
|---|---|---|
| Required | variable "x" { type = string } | The module cannot infer a safe value (names, target VPC, account-specific ARNs). |
| Optional with default | variable "x" { type = bool; default = true } | A safe, common-case value exists that most callers will accept. |
| Optional, nullable | default = null + downstream coalesce/conditional | An off switch where “unset” is meaningfully different from a concrete default. |
| Structured | type = object({...}) with optional(...) | Several related settings travel together. |
Tip: Hiding implementation detail goes both ways. Don’t expose a
vpcinput as a rawaws_vpcobject — accept a plainvpc_idstring. Callers shouldn’t have to construct provider-shaped objects to use your module.
Warning: Removing or renaming an output, or making an optional input required, is a breaking change. Bump the major version (see module versioning) rather than slipping it into a patch release.
Best Practices
- Keep the input surface small; prefer sensible defaults over forcing callers to specify everything.
- Always set an explicit
typeand addvalidationblocks so bad input fails at plan time with a clear message. - Write a meaningful
descriptionfor every variable and output — these render in generated docs and the registry. - Mark secret inputs and secret-derived outputs
sensitive = true. - Accept primitive references (IDs, ARNs, names) rather than whole provider objects to avoid leaking internals.
- Export only the outputs consumers need; treat the output set as a versioned contract.
- Use
optional()object attributes with defaults to model grouped settings cleanly — works in both Terraform 1.3+ and OpenTofu.