Skip to content
Infrastructure as Code iac modules 5 min read

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 styleDeclarationUse when
Requiredvariable "x" { type = string }The module cannot infer a safe value (names, target VPC, account-specific ARNs).
Optional with defaultvariable "x" { type = bool; default = true }A safe, common-case value exists that most callers will accept.
Optional, nullabledefault = null + downstream coalesce/conditionalAn off switch where “unset” is meaningfully different from a concrete default.
Structuredtype = object({...}) with optional(...)Several related settings travel together.

Tip: Hiding implementation detail goes both ways. Don’t expose a vpc input as a raw aws_vpc object — accept a plain vpc_id string. 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 type and add validation blocks so bad input fails at plan time with a clear message.
  • Write a meaningful description for 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.
Last updated June 14, 2026
Was this helpful?