Cloud  /  Terraform

IaC Terraform 50 guides · updated 2026

Infrastructure as code done right — providers, state, reusable modules, and the workflow patterns that keep multi-cloud deployments sane in 2026.

Terraform Local Modules

A local module is a module referenced by a relative filesystem path — source = "./modules/networking". Unlike public Registry modules, local modules live in your repository, evolve with your codebase, and are perfect for patterns that are specific to your organization or too specialized for public sharing.


When to Write a Local Module

Write a local module when you find yourself copy-pasting the same group of resources across two or more places in the same codebase. The rule of three applies: one use = inline code, two uses = consider a module, three uses = definitely a module.

Good candidates:


File Layout

infrastructure/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
└── modules/
├── s3-bucket/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── rds-instance/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── lambda-function/
├── main.tf
├── variables.tf
├── outputs.tf
└── iam.tf

Building a Local Module: S3 Bucket Example

modules/s3-bucket/variables.tf
variable "bucket_name" {
description = "Globally unique S3 bucket name"
type = string
}
variable "versioning_enabled" {
type = bool
default = true
}
variable "log_bucket" {
description = "Optional: bucket name to send access logs to"
type = string
default = null
}
variable "tags" {
type = map(string)
default = {}
}
modules/s3-bucket/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
}
resource "aws_s3_bucket_logging" "this" {
count = var.log_bucket != null ? 1 : 0
bucket = aws_s3_bucket.this.id
target_bucket = var.log_bucket
target_prefix = "${var.bucket_name}/"
}
modules/s3-bucket/outputs.tf
output "bucket_id" {
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}
output "bucket_domain_name" {
value = aws_s3_bucket.this.bucket_domain_name
}

Calling the Local Module

# root main.tf
module "app_assets" {
source = "./modules/s3-bucket"
bucket_name = "mycompany-app-assets-${var.environment}"
versioning_enabled = true
tags = {
Environment = var.environment
Service = "app-assets"
Team = "platform"
}
}
module "data_lake" {
source = "./modules/s3-bucket"
bucket_name = "mycompany-data-lake-${var.environment}"
versioning_enabled = true
log_bucket = module.access_logs.bucket_id
tags = {
Environment = var.environment
Service = "data-lake"
Team = "data-engineering"
}
}
# Use module outputs
resource "aws_iam_policy" "app_read_assets" {
policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = [
module.app_assets.bucket_arn,
"${module.app_assets.bucket_arn}/*"
]
}]
})
}

Local Module with count and for_each

# Create multiple S3 buckets from a map
variable "buckets" {
type = map(object({
versioning = bool
log_bucket = optional(string)
}))
default = {
assets = { versioning = true }
backups = { versioning = true }
temp-files = { versioning = false }
}
}
module "buckets" {
for_each = var.buckets
source = "./modules/s3-bucket"
bucket_name = "mycompany-${each.key}-${var.environment}"
versioning_enabled = each.value.versioning
log_bucket = each.value.log_bucket
}
# Access individual bucket outputs
output "bucket_arns" {
value = { for k, v in module.buckets : k => v.bucket_arn }
}

Documenting Local Modules

# modules/s3-bucket/variables.tf — use description for all variables
variable "bucket_name" {
description = "Globally unique bucket name. Convention: mycompany-{purpose}-{environment}"
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
error_message = "Bucket name must be 3-63 characters, lowercase, and start/end with letter or number."
}
}
# modules/s3-bucket/outputs.tf — describe what callers typically use these for
output "bucket_arn" {
description = "ARN of the S3 bucket — use in IAM policies"
value = aws_s3_bucket.this.arn
}

Updating Local Modules

Local modules don’t have version pinning — a change to the module source takes effect the next time the root module is initialized and applied. This is a double-edged sword:

For breaking changes to local modules, add validation to catch incompatible usage:

variable "enable_website" {
type = bool
default = false
# If you change the behavior of this flag, update all callers
}
lifecycle {
precondition {
condition = !var.enable_website || var.bucket_name != null
error_message = "enable_website requires bucket_name to be set."
}
}