Skip to content
Infrastructure as Code iac modules 4 min read

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.

FilePurpose
main.tfResource and data source definitions—the actual infrastructure.
variables.tfInput variable declarations (the module’s parameters).
outputs.tfOutput values exposed to the calling configuration.
versions.tfterraform block pinning required Terraform and provider versions.
README.mdUsage, 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), or object({...}) over a bare string or any. 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 type and description on every variable, and validate constraints with validation blocks rather than provider errors.
  • Output only what callers need—stable identifiers like IDs and ARNs—so the contract stays small and predictable.
  • Pin required_version and provider versions in versions.tf so the module produces consistent plans everywhere it runs.
  • Never hardcode provider blocks inside a module; let the root configuration pass providers in so the module stays reusable.
  • Document inputs and outputs in README.md (generate it with terraform-docs) and keep it in sync with the code.
Last updated June 14, 2026
Was this helpful?