Writing Your First Terraform Module
Scenario
You've been copying the same S3 bucket + versioning + encryption config across 6 different projects. Every copy has slight variations and bugs. You want to package this reusable pattern into a module — define it once, call it many times with different inputs.
- Create a local module in modules/s3-bucket/ with main.tf, variables.tf, outputs.tf.
- The module should create an S3 bucket with versioning and encryption, accepting inputs for name, environment, and versioning toggle.
- Call the module from the root config using module {} with source = "./modules/s3-bucket".
- Call the module twice with different inputs to create two buckets.
- Reference module outputs in the root config's outputs.
Additional Context
A module is just a directory with .tf files. The directory you run terraform apply from is the "root module." Any directory you reference with source is a "child module."
Modules communicate through inputs (variables) and outputs. The calling module passes values as arguments; the child module exposes results through outputs. This is the only interface — resources inside a module are not directly accessible from outside.
modules/s3-bucket/variables.tf
# Module inputs — the interface callers use
variable "bucket_name" {
description = "Name for the S3 bucket."
type = string
}
variable "environment" {
description = "Environment label (dev, staging, prod)."
type = string
default = "dev"
}
variable "enable_versioning" {
description = "Toggle bucket versioning."
type = bool
default = true
}modules/s3-bucket/main.tf
# Module resources — the implementation
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = {
Name = var.bucket_name
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.enable_versioning ? "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"
}
}
}modules/s3-bucket/outputs.tf
# Module outputs — exposed to the calling module
output "bucket_id" { value = aws_s3_bucket.this.id }
output "bucket_arn" { value = aws_s3_bucket.this.arn }
output "bucket_region" { value = aws_s3_bucket.this.region }main.tf (root module)
# Root module — calls the s3-bucket module twice
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = "us-east-1" }
data "aws_caller_identity" "current" {}
# First call — application data bucket
module "app_bucket" {
source = "./modules/s3-bucket"
bucket_name = "app-data-${data.aws_caller_identity.current.account_id}"
environment = "dev"
enable_versioning = true
}
# Second call — logs bucket (different inputs)
module "logs_bucket" {
source = "./modules/s3-bucket"
bucket_name = "app-logs-${data.aws_caller_identity.current.account_id}"
environment = "dev"
enable_versioning = false # No versioning for logs
}outputs.tf (root module)
# Reference module outputs with: module.MODULE_NAME.OUTPUT_NAME
output "app_bucket_name" { value = module.app_bucket.bucket_id }
output "app_bucket_arn" { value = module.app_bucket.bucket_arn }
output "logs_bucket_name"{ value = module.logs_bucket.bucket_id }
output "logs_bucket_arn" { value = module.logs_bucket.bucket_arn }Workflow Commands
# Create the module directory structure
mkdir -p modules/s3-bucket
terraform init # Downloads providers and registers the local module
terraform plan # Shows 6 resources (3 per module call)
terraform apply
terraform destroyApply complete! Resources: 6 added, 0 changed, 0 destroyed. Outputs: app_bucket_arn = "arn:aws:s3:::app-data-123456789012" app_bucket_name = "app-data-123456789012" logs_bucket_arn = "arn:aws:s3:::app-logs-123456789012" logs_bucket_name = "app-logs-123456789012"

