Creating a Module
A Terraform module is just a directory containing .tf files. What turns an ordinary configuration into a reusable module is a clear contract: well-named input variables, a focused set of resources, and outputs that expose only what callers need. Getting that contract right is what makes a module composable, testable, and safe to version. This page walks through the standard layout and builds a small, real S3 bucket module from scratch. Everything here works identically with OpenTofu.
Standard file layout
By convention a module splits its code across three files. The split is purely organizational—Terraform loads every .tf file in the directory regardless of name—but following the convention makes any module instantly readable to other engineers.
| File | Purpose |
|---|---|
main.tf | Resource and data source definitions—the actual infrastructure. |
variables.tf | Input variable declarations (the module’s parameters). |
outputs.tf | Output values exposed to the calling configuration. |
versions.tf | terraform block pinning required Terraform and provider versions. |
README.md | Usage, inputs, and outputs (often generated by terraform-docs). |
A typical module directory looks like this:
modules/s3-bucket/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
└── README.md
Defining inputs
Inputs are declared with variable blocks. Always give each variable a type and a description; add a default only when the value is genuinely optional. Use validation blocks to catch bad input early—at plan time—rather than letting a misconfiguration reach the provider API.
# variables.tf
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, numbers, dots, hyphens."
}
}
variable "versioning_enabled" {
description = "Whether to enable object versioning."
type = bool
default = true
}
variable "tags" {
description = "Tags applied to all resources in this module."
type = map(string)
default = {}
}
Prefer specific types like
bool,map(string), orobject({...})over a barestringorany. Strong typing turns silent runtime failures into clear, actionable plan-time errors.
Writing the resources
main.tf declares the infrastructure and wires variables into it. A focused module owns one logical unit of infrastructure—here, an S3 bucket plus its directly related configuration. Modern AWS provider syntax (v4+) splits bucket settings into separate resources, so the module composes them together for the caller.
# main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = var.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.versioning_enabled ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Pin your requirements so the module behaves consistently wherever it is consumed:
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 6.0"
}
}
}
Defining outputs
Outputs are the module’s return values—the only way a caller can read attributes of resources created inside it. Expose what consumers will actually reference (IDs, ARNs, names) and nothing more. Keeping the output surface small is part of keeping the contract clean.
# outputs.tf
output "bucket_id" {
description = "The name (ID) of the bucket."
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "The ARN of the bucket."
value = aws_s3_bucket.this.arn
}
output "bucket_domain_name" {
description = "The bucket's regional domain name."
value = aws_s3_bucket.this.bucket_regional_domain_name
}
Using the module
A root configuration calls the module through a module block, passing inputs and consuming outputs:
module "logs_bucket" {
source = "./modules/s3-bucket"
bucket_name = "acme-app-logs-2025"
versioning_enabled = true
tags = {
Environment = "production"
Team = "platform"
}
}
output "logs_bucket_arn" {
value = module.logs_bucket.bucket_arn
}
Initialize and inspect the plan:
terraform init
terraform plan
Output:
Terraform will perform the following actions:
# module.logs_bucket.aws_s3_bucket.this will be created
+ resource "aws_s3_bucket" "this" {
+ bucket = "acme-app-logs-2025"
+ arn = (known after apply)
+ tags = {
+ "Environment" = "production"
+ "Team" = "platform"
}
}
# module.logs_bucket.aws_s3_bucket_versioning.this will be created
# module.logs_bucket.aws_s3_bucket_server_side_encryption_configuration.this will be created
# module.logs_bucket.aws_s3_bucket_public_access_block.this will be created
Plan: 4 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ logs_bucket_arn = (known after apply)
Resources inside a module are addressed with a
module.<name>.prefix in the plan and in state. This namespacing is automatic and is why two calls to the same module never collide.
Best practices
- Keep each module focused on one logical concern; resist the urge to bundle unrelated resources behind a single interface.
- Always set
typeanddescriptionon every variable, and validate constraints withvalidationblocks rather than provider errors. - Output only what callers need—stable identifiers like IDs and ARNs—so the contract stays small and predictable.
- Pin
required_versionand provider versions inversions.tfso the module produces consistent plans everywhere it runs. - Never hardcode
providerblocks inside a module; let the root configuration pass providers in so the module stays reusable. - Document inputs and outputs in
README.md(generate it withterraform-docs) and keep it in sync with the code.