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:
- ECS services (task definition + service + security group + log group)
- RDS instances (instance + parameter group + subnet group + security group)
- S3 buckets with standard configuration (versioning + encryption + public access block)
- Lambda functions (function + IAM role + log group + permissions)
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.tfBuilding a Local Module: S3 Bucket Example
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 = {}}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}/"}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.tfmodule "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 outputsresource "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 mapvariable "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 outputsoutput "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 variablesvariable "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 foroutput "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:
- Changes propagate immediately (no version bump needed)
- Breaking changes affect all callers at once
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." }}