Writing Terraform Modules
Learn how to write reusable Terraform modules, structure them with main.tf, variables.tf, and outputs.tf, and publish or share modules across projects.
Why Write Your Own Terraform Modules?
Using existing modules is valuable, but sooner or later most teams need to create their own. Maybe you want an internal standard for S3 buckets, a reusable VPC pattern, or a consistent application stack with approved tagging, logging, and security defaults.
That is where writing Terraform modules becomes important.
A Terraform module is just a directory of .tf files, but a well-written module is much more than a folder. It is a reusable infrastructure product with:
- a clear purpose
- documented inputs
- intentional outputs
- sensible defaults
- predictable behavior
Good modules help teams avoid copy-paste, reduce configuration drift, and apply organizational standards consistently.
When Should You Create a Module?
Not every group of resources deserves its own module. Creating too many tiny modules can make Terraform harder to follow.
Create a module when at least one of these is true:
1. You repeat the same pattern
If you copy the same S3 bucket, IAM role, or application stack into several projects, that is a strong sign the pattern should become a module.
2. You want standard defaults
A module can enforce defaults such as:
- encryption enabled
- public access blocked
- required tags applied
- monitoring turned on
3. You want a simpler interface
A raw provider resource may expose many arguments. A module can hide unnecessary complexity behind a smaller, friendlier interface.
4. You want DRY infrastructure code
DRY means Don't Repeat Yourself. Modules are one of Terraform's main tools for applying the DRY principle.
A Typical Terraform Module Structure
A common module layout looks like this:
modules/
vpc/
main.tf
variables.tf
outputs.tf
README.md
main.tf, Resources & Logic
Contains the main resources and logic for the module.
variables.tf, Input Definitions
Defines the module's input variables.
outputs.tf, Module File Structure
README.md
Explains what the module does, how to use it, and what inputs and outputs exist.
This file is not strictly required by Terraform, but it is a best practice for shared modules.
Building a Simple Module: Example S3 Bucket Module
Let us create a reusable module for a secure logging bucket.
variables.tf
variable "bucket_name" {
description = "Globally unique S3 bucket name"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "tags" {
description = "Additional tags to apply"
type = map(string)
default = {}
}
variable "enable_versioning" {
description = "Whether to enable bucket versioning"
type = bool
default = true
}
main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = merge(
{
Name = var.bucket_name
Environment = var.environment
ManagedBy = "Terraform"
},
var.tags
)
}
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"
}
}
}
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
}
outputs.tf, Defining Module Outputs
output "bucket_id" {
description = "ID of the bucket"
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "ARN of the bucket"
value = aws_s3_bucket.this.arn
}
This module now encapsulates a secure S3 bucket pattern that can be reused everywhere.
Calling a Local Module
You use a local module from the root module with a relative source path.
module "app_logs" {
source = "./modules/s3-logging-bucket"
bucket_name = "company-prod-app-logs"
environment = "prod"
enable_versioning = true
tags = {
Team = "platform"
}
}
This is often the first way beginners learn modules, and it is a great start because everything lives in one repository.
Module Inputs: Designing a Good Interface
Module inputs are declared with variables. They form the public API of the module.
A good module interface is:
- small
- clear
- documented
- type-safe
- validated when necessary
Good input design example
variable "project_name" {
description = "Project name used in resource names"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
Avoid giant interfaces when possible
If a module needs 40 inputs, that may be a sign that:
- the module is doing too much
- the interface is too low-level
- some logic should move into locals or defaults
Good modules expose the meaningful decisions and hide repetitive implementation detail.
Module Outputs: Returning Useful Results
Outputs let callers use values from the module.
For an S3 module, useful outputs may include:
- bucket ID
- bucket ARN
- regional domain name
For a VPC module, useful outputs may include:
- VPC ID
- subnet IDs
- route table IDs
Example:
output "bucket_domain_name" {
description = "Bucket DNS name"
value = aws_s3_bucket.this.bucket_domain_name
}
The root module can then reference:
module.app_logs.bucket_domain_name
Outputs make modules composable.
Nested Modules
A module can call another module. This is called a nested module.
Example structure:
modules/
platform/
main.tf
networking/
main.tf
logging/
main.tf
Inside modules/platform/main.tf, you might have:
module "networking" {
source = "../networking"
project_name = var.project_name
environment = var.environment
}
module "logging" {
source = "../logging"
project_name = var.project_name
environment = var.environment
}
When nested modules help
Nested modules are useful when you want to compose several lower-level building blocks into a higher-level platform module.
When nested modules become a problem
Too much nesting can make Terraform harder to trace. If people have to jump through five layers of modules to understand one resource, the design may be too complex.
Module Sources: Local Path, Git, and Terraform Registry
Terraform can load modules from several places.
1. Local path
source = "./modules/vpc"
Best when:
- learning modules
- sharing modules inside one repository
- developing and testing quickly
2. Git source
Terraform can pull modules from a Git repository.
Conceptually:
source = "git::https://github.com/example-org/terraform-modules.git//vpc?ref=v1.2.0"
This is useful for private internal module repositories and versioned reuse across many projects.
3. Terraform Registry
source = "example-org/vpc/aws"
version = "1.2.0"
The Registry is ideal when you want discoverable published modules with documented versions.
Publishing to the Terraform Registry (Briefly)
You do not need to publish modules to learn Terraform, but it helps to understand the concept.
Publishing to the Terraform Registry generally involves:
- storing the module in a properly named Git repository
- tagging versions
- documenting inputs and outputs well
- following Registry source naming conventions
Public Registry publishing is useful for open-source modules. Private Registry features or internal Git-based distribution are common for company modules.
Testing Modules with terraform plan
Terraform modules should be tested before teams rely on them.
The easiest first test is to create a small example root module that calls your module, then run:
terraform init
terraform plan
This checks whether:
- the module syntax is valid
- variables are wired correctly
- required inputs are clear
- outputs resolve correctly
- providers and resources are configured as expected
Example test structure
examples/
basic/
main.tf
Inside examples/basic/main.tf:
module "test_bucket" {
source = "../../modules/s3-logging-bucket"
bucket_name = "example-dev-logs-bucket"
environment = "dev"
}
Running terraform plan in the example directory is a practical way to validate module behavior.
Writing Modules for Readability
Good modules are not only reusable; they are also understandable.
Helpful habits
- keep resource names consistent (
thisis a common internal naming convention) - group related resources together
- use locals for repeated naming or tags
- add descriptions to every variable and output
- use validation for important inputs
- keep the README example minimal and practical
Example of using locals inside a module
locals {
common_tags = merge(
{
Environment = var.environment
ManagedBy = "Terraform"
},
var.tags
)
}
This improves maintainability without exposing unnecessary complexity to callers.
Versioning Your Own Modules
If modules are shared across teams, versioning matters.
Common practices include:
- tagging releases in Git
- documenting breaking changes
- updating version constraints in consuming projects deliberately
Versioning protects consumers from surprise behavior changes.
Common Beginner Mistakes When Writing Modules
Creating modules too early
If a pattern appears only once, a module may be unnecessary.
Exposing too many inputs
A module should present a useful abstraction, not mirror every low-level provider argument by default.
Forgetting outputs
If callers need important values and the module does not expose them, people will struggle to compose modules together.
No documentation
A module without a README, descriptions, or examples is much harder for others to adopt.
No test example
At minimum, have a simple example root module and run terraform plan against it.
Best Practices for Writing Terraform Modules
- Create modules for repeated patterns, not for everything.
- Keep module interfaces focused and documented.
- Use
main.tf,variables.tf, andoutputs.tfas a clean baseline structure. - Add validation for important inputs.
- Return useful outputs intentionally.
- Use local modules first, then move to Git or Registry distribution when the module matures.
- Test modules with a real example and
terraform plan. - Version shared modules deliberately.
Complete Example: Reusable VPC Module Pattern
A more advanced module might include:
modules/
vpc/
main.tf
variables.tf
outputs.tf
README.md
examples/
vpc-basic/
main.tf
Possible inputs
project_nameenvironmentvpc_cidrpublic_subnet_cidrsprivate_subnet_cidrsavailability_zones
Possible outputs
vpc_idpublic_subnet_idsprivate_subnet_idsinternet_gateway_id
This kind of module becomes a reusable building block for many application stacks.
Final Thoughts
Writing Terraform modules is how you turn working infrastructure code into reusable infrastructure products. Good modules reduce duplication, enforce standards, and make large Terraform codebases easier to scale.
The most important ideas are:
- create modules for repeated patterns
- keep the structure simple and predictable
- define clear inputs and outputs
- use local paths, Git, or the Registry as sources
- test with real example configurations and
terraform plan - document and version modules so others can trust them
Once you can both use modules and write them, you have one of the most valuable Terraform skills for real-world DevOps work.
Over time, strong internal modules become part of your platform engineering foundation. They let teams move faster without repeating low-level infrastructure decisions, and they make reviews easier because the most important patterns already live behind tested, documented module interfaces.
Knowledge Check
Question 1: Module Structure
Which set of files is the most common starting structure for a reusable Terraform module?
Question 2: When to Create a Module
Which situation is the strongest signal that you should create a Terraform module?
Question 3: Testing Modules
What is a practical first step for testing a new Terraform module?