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:
Good modules help teams avoid copy-paste, reduce configuration drift, and apply organizational standards consistently.
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:
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.
A module can enforce defaults such as:
A raw provider resource may expose many arguments. A module can hide unnecessary complexity behind a smaller, friendlier interface.
DRY means Don't Repeat Yourself. Modules are one of Terraform's main tools for applying the DRY principle.
A common module layout looks like this:
modules/
vpc/
main.tf
variables.tf
outputs.tf
README.mdmain.tf, Resources & LogicContains the main resources and logic for the module.
variables.tf, Input DefinitionsDefines the module's input variables.
outputs.tf, Module File StructureREADME.mdExplains 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.
Let us create a reusable module for a secure logging bucket.
variables.tfvariable "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.tfresource "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 Outputsoutput "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.
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 are declared with variables. They form the public API of the module.
A good module interface is:
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."
}
}If a module needs 40 inputs, that may be a sign that:
Good modules expose the meaningful decisions and hide repetitive implementation detail.
Outputs let callers use values from the module.
For an S3 module, useful outputs may include:
For a VPC module, useful outputs may include:
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_nameOutputs make modules composable.
A module can call another module. This is called a nested module.
Example structure:
modules/
platform/
main.tf
networking/
main.tf
logging/
main.tfInside 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
}Nested modules are useful when you want to compose several lower-level building blocks into a higher-level platform module.
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.
Terraform can load modules from several places.
source = "./modules/vpc"Best when:
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.
source = "example-org/vpc/aws"
version = "1.2.0"The Registry is ideal when you want discoverable published modules with documented versions.
You do not need to publish modules to learn Terraform, but it helps to understand the concept.
Publishing to the Terraform Registry generally involves:
Public Registry publishing is useful for open-source modules. Private Registry features or internal Git-based distribution are common for company modules.
terraform planTerraform 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 planThis checks whether:
examples/
basic/
main.tfInside 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.
Good modules are not only reusable; they are also understandable.
this is a common internal naming convention)locals {
common_tags = merge(
{
Environment = var.environment
ManagedBy = "Terraform"
},
var.tags
)
}This improves maintainability without exposing unnecessary complexity to callers.
If modules are shared across teams, versioning matters.
Common practices include:
Versioning protects consumers from surprise behavior changes.
If a pattern appears only once, a module may be unnecessary.
A module should present a useful abstraction, not mirror every low-level provider argument by default.
If callers need important values and the module does not expose them, people will struggle to compose modules together.
A module without a README, descriptions, or examples is much harder for others to adopt.
At minimum, have a simple example root module and run terraform plan against it.
main.tf, variables.tf, and outputs.tf as a clean baseline structure.terraform plan.A more advanced module might include:
modules/
vpc/
main.tf
variables.tf
outputs.tf
README.md
examples/
vpc-basic/
main.tfproject_nameenvironmentvpc_cidrpublic_subnet_cidrsprivate_subnet_cidrsavailability_zonesvpc_idpublic_subnet_idsprivate_subnet_idsinternet_gateway_idThis kind of module becomes a reusable building block for many application stacks.
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:
terraform planOnce 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.
<MCQExercise exercise={{ id: 'tf-writing-modules-q1', title: 'Question 1: Module Structure', description: 'Which set of files is the most common starting structure for a reusable Terraform module?', options: [ { id: 'a', text: 'main.tf, variables.tf, outputs.tf, README.md' }, { id: 'b', text: 'Dockerfile, package.json, README.md' }, { id: 'c', text: 'state.tf, backend.tf, logs.tf' }, { id: 'd', text: 'provider.tf only' }, ], correctId: 'a', explanation: 'A common Terraform module structure includes main.tf for resources, variables.tf for inputs, outputs.tf for exposed values, and README.md for documentation.', wrongExplanations: { b: 'Those files are common in application repositories, not Terraform module structure.', c: 'There is no standard Terraform module structure based on state.tf or logs.tf.', d: 'A single provider.tf file is not a typical reusable module structure.', }, }} />
<MCQExercise exercise={{ id: 'tf-writing-modules-q2', title: 'Question 2: When to Create a Module', description: 'Which situation is the strongest signal that you should create a Terraform module?', options: [ { id: 'a', text: 'You have a repeated infrastructure pattern that appears across multiple projects' }, { id: 'b', text: 'You want to avoid ever using variables again' }, { id: 'c', text: 'You only need one small resource a single time' }, { id: 'd', text: 'You want Terraform to stop using providers' }, ], correctId: 'a', explanation: 'Repeated patterns are the clearest reason to create a module because modules reduce duplication and make that shared logic easier to maintain.', wrongExplanations: { b: 'Modules still rely heavily on variables for their interfaces.', c: 'A one-off small resource usually does not need its own module.', d: 'Providers are still required to interact with infrastructure platforms.', }, }} />
<MCQExercise exercise={{ id: 'tf-writing-modules-q3', title: 'Question 3: Testing Modules', description: 'What is a practical first step for testing a new Terraform module?', options: [ { id: 'a', text: 'Delete the state file and hope Terraform reconstructs the module automatically' }, { id: 'b', text: 'Create a small example configuration that calls the module and run terraform plan' }, { id: 'c', text: 'Publish the module to the public Registry before trying it locally' }, { id: 'd', text: 'Remove all outputs to keep the module hidden' }, ], correctId: 'b', explanation: 'A simple example root module plus terraform plan is a practical, beginner-friendly way to validate variables, resources, and outputs before sharing the module widely.', wrongExplanations: { a: 'Deleting state is unrelated to module testing and can be dangerous.', c: 'You should test locally before publishing broadly.', d: 'Outputs are often necessary for module usability and are not something you remove just to test.', }, }} />
Keep the required inputs small and give everything else a sensible default, so the simplest use is a few lines. Use variable validation blocks to reject bad input at plan time with a clear message rather than letting a provider error surface later. Expose outputs for anything a caller might plausibly need, IDs, ARNs, endpoints, since adding one later is easy but callers cannot reach inside. A good module is judged by how short its typical call site is.
By convention main.tf for resources, variables.tf for inputs, outputs.tf for outputs, and versions.tf for Terraform and provider constraints. A README.md documenting inputs, outputs and a usage example matters more than people expect, since it is what a future colleague reads instead of the source. terraform-docs generates the input and output tables automatically from your declarations, which keeps documentation honest.
No. Modules should declare required_providers in versions.tf to state which providers and versions they need, but the provider configuration, region, credentials, aliases, belongs in the root module and is inherited. A module with its own provider block cannot be used with multiple regions or accounts, and cannot be removed cleanly from state. Pass provider aliases explicitly via the providers argument when a module genuinely needs a non-default one.
Put each in its own Git repository, or use a monorepo with tags, and source them with a pinned ?ref=v1.2.0. Tag releases with semantic versioning and treat an input rename as a breaking change, because it is. For larger organisations, a private Terraform registry gives proper version constraints and discoverability. Whatever you choose, pinning is the part that matters. An unpinned internal module is as risky as an unpinned external one.
Start with the cheap layers: terraform validate and terraform fmt -check in CI, plus tfsec or Checkov for security policy. Beyond that, the native terraform test framework runs assertions against plan or apply results using .tftest.hcl files, which is enough for most modules. Terratest, which drives real infrastructure from Go, is heavier but suited to modules where a genuine end-to-end check is worth the runtime and cost.
Learn how Terraform data sources read existing infrastructure, how the data block works, how to reference its attributes and when to use data over resource.
Choose between Terraform count and for_each: see why count's positional identity can destroy resources, when each is correct and how to migrate safely.
Learn how the Terraform count meta-argument works: use count.index, build conditional resources, reference counted resources and know when count fits.