linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/kb/Modules/tf-module-inputs-outputs

kb/modules ── Modules ── intermediate

The module contract: input variables and outputs

From the outside, a module is visible only through its input variables (what it accepts) and its output values (what it returns). Everything else is implementation detail. A good module hides its resources behind this contract so you can rewrite the body without touching the calls in the root module.

view as markdownaka: terraform-module-contract, terraform-module-interface

Why a module needs a contract

A module is a black box to the root. The root knows one thing: "I pass these inputs, I get these outputs." What happens inside is irrelevant. This buys you a few things:

  • You can rewrite the resources inside the module without editing the root.
  • You can use one module from ten different roots.
  • You can swap a provider resource (aws_s3_bucket_v2 instead of aws_s3_bucket) during an upgrade without cascading edits on the outside.

The contract is the set of variable blocks and output blocks. Everything else in the module is implementation detail.

Input variables

Each variable block is a parameter of the module. It has a type, a description, an optional default, and optional validation:

hcl
# modules/s3-bucket/variables.tf
variable "name" {
  type        = string
  description = "S3 bucket name. Must be globally unique."
  validation {
    condition     = length(var.name) >= 3 && length(var.name) <= 63
    error_message = "Bucket name must be 3-63 characters (AWS rule)."
  }
  validation {
    condition     = can(regex("^[a-z0-9.-]+$", var.name))
    error_message = "Bucket name: lowercase, digits, dot, and hyphen only."
  }
}
variable "versioning_enabled" {
  type        = bool
  description = "Enable versioning. Usually true for prod buckets."
  default     = false
}
variable "lifecycle_rules" {
  type = list(object({
    id      = string
    enabled = bool
    transition = optional(object({
      days   = number
      target = string
    }))
  }))
  description = "Lifecycle rules. See examples/."
  default     = []
}

No default means a required argument

If a variable has no default, the caller must pass a value. Otherwise Terraform complains at plan. That is good: arguments that are required should be required.

Type matters

With type = string, the user can only pass a string. Not 42 (a number), not ["a", "b"] (a list). A type mistake is caught at plan, not at apply. See hcl-types.

Validation guards the module boundary

Validation catches what the type cannot:

  • "Length 3-63" means nothing to the string type.
  • "Lowercase only" means nothing to the string type.
  • "days > 0" means nothing to the number type.

Validation runs at plan, before any API calls. It is a cheap way to reject invalid configurations. Without it, the error surfaces over in AWS as "InvalidBucketName", and the person using the module does not immediately see what went wrong.

Output values

Outputs are what the module returns to its parent:

hcl
# modules/s3-bucket/outputs.tf
output "arn" {
  value       = aws_s3_bucket.this.arn
  description = "Bucket ARN. Used for IAM policies."
}
output "bucket" {
  value       = aws_s3_bucket.this.bucket
  description = "Bucket name (as passed in the input)."
}
output "regional_domain_name" {
  value       = aws_s3_bucket.this.bucket_regional_domain_name
  description = "DNS name for direct S3 requests."
}

In the root module you read them as module.<name>.<output_name>:

hcl
output "logs_arn" {
  value = module.logs.arn
}
resource "aws_iam_policy" "logs_writer" {
  policy = jsonencode({
    Statement = [{
      Effect   = "Allow"
      Action   = "s3:PutObject"
      Resource = "${module.logs.arn}/*"
    }]
  })
}

sensitive output

If a secret ends up in an output, mark it:

hcl
output "access_key_secret" {
  value     = aws_iam_access_key.user.secret
  sensitive = true
}

This does not encrypt the value. The state still holds the secret in the clear (which is bad, see tf-state). All sensitive = true does is hide the value from the CLI output of terraform output and plan. Real secret protection comes from a secret manager (Vault, SSM Parameter Store), not from sensitive = true.

An output does not get depends_on implicitly

If an output depends on a resource that exists only under certain conditions, add depends_on explicitly:

hcl
output "lifecycle_rule_id" {
  value      = length(aws_s3_bucket_lifecycle_configuration.this) > 0 ? aws_s3_bucket_lifecycle_configuration.this[0].id : null
  depends_on = [aws_s3_bucket_lifecycle_configuration.this]
}

How to design the contract

Parameterize what changes

Do not add a variable "just in case." A variable should reflect a real difference between the use cases of the module.

hcl
# BAD: just in case
variable "force_destroy" {
  type    = bool
  default = false
}
variable "object_ownership" {
  type    = string
  default = "BucketOwnerEnforced"
}
# plus 15 more variables that nobody ever changes
# GOOD: only what actually differs between use cases
variable "name" { type = string }
variable "tags" { type = map(string), default = {} }
variable "lifecycle_rules" { type = list(object({...})), default = [] }

Keep non-parameterized values in locals inside the module.

Names are the contract

Rename variable "name" to variable "bucket_name" and you have a breaking change for every root that uses the module. The same rules apply as with an API: you cannot break names, or it becomes a new major version. See tf-module-versioning.

Group complex parameters into an object

Ten flat variable "rule_X_..." declarations are worse than one variable "rules" of type list(object(...)). With an object type the user sees the whole schema at once and does not guess which variables go together.

Pitfalls

  • Validation runs on every plan. If a validation contains a heavy regex or a large contains([...100 elements...], var.x), that costs time. Usually it does not matter, but in a module that is called 50 times through for_each it can become noticeable.

  • You need description. Without description, terraform-docs will not generate a decent README, and the person using the module ends up reading the HCL itself to figure out what bucket_acl means. It is also free documentation that the IDE shows on hover.

  • default = null is not the same as no default. default = null means "the default value is null, and that is valid." If you want the argument to be required, drop the default entirely rather than setting it to null.

  • sensitive = true on an input does not encrypt, it only masks. The same caveats apply as for an output: the value still lives in the state. See tf-state.

  • An output that references a resource through count breaks when count = 0. aws_s3_bucket.this[0].arn fails with "index 0 out of range" if the resource is absent. Use try(aws_s3_bucket.this[0].arn, null) or a splat: aws_s3_bucket.this[*].arn (which returns a list, possibly empty).

  • You cannot write a validation that references another variable. TF 1.9+ added cross-variable validation through precondition in lifecycle, but inside the variable block itself you still get only self-validation.

§ команды

bash
terraform plan -var-file=examples/dev.tfvars

Test the module contract against a concrete set of values.

bash
terraform-docs markdown table modules/s3-bucket > modules/s3-bucket/README.md

Generate a README with a table of variables and outputs. Install it separately: github.com/terraform-docs.

bash
terraform console -chdir=modules/s3-bucket

A REPL inside the module. Handy for checking the expressions used in outputs.

§ см. также

  • tf-module-basicsModule: a reusable piece of infrastructureA module is any directory that contains `.tf` files and can be referenced via a `module` block. The root module is the directory where you run terraform. Child modules are the ones being called. The module contract: input variables (what it accepts), output values (what it exposes). Everything else is an implementation detail.
  • tf-variableThe variable block: input to your configurationA variable is a parameter that receives its value from outside the configuration (CLI, environment variable, .tfvars file). You declare it in HCL with type, default, description, and validation, then reference it as var.name. Variables remove hardcoded values and let one HCL configuration serve multiple environments.
  • tf-outputThe output block: what Terraform exposes to the outsideAn output is a value that Terraform displays after apply and stores in state. Use it to (a) show the user the ID or ARN of a created resource, (b) pass values between modules, or (c) feed values to scripts via `terraform output -raw`.
  • tf-module-compositionModule composition: a module of modules and passing providersA module can call other modules inside its own main.tf, using the same `module` block. The address in state becomes `module.A.module.B.<type>.<name>`. Providers are inherited by default, but with several aliases (multi-region, multi-account) you pass them explicitly through `providers = { aws = aws.eu }`. `for_each` over a module works with TF 0.13+.
Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies