DevOpsLesson
DevOpsLesson

Free, comprehensive DevOps tutorials and learning roadmaps. Master Docker, Kubernetes, CI/CD, and more.

Stay Updated

Get notified about new tutorials and features.

Tutorials

  • What is DevOps?
  • Docker Tutorial
  • Terraform Tutorial
  • CI/CD Pipeline
  • All Tutorials

Roadmaps

  • DevOps Engineer
  • Cloud Engineer
  • SRE Path
  • All Roadmaps

Company

  • About Us
  • Blog
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 DevOpsLesson. All rights reserved.

DOCKERKUBERNETESTERRAFORMAWSCI/CDLINUXGITDEVOPS ROADMAPCLOUD ROADMAPSRE ROADMAPGIT CHEATSHEETDOCKER CHEATSHEETK8S CHEATSHEETTF CHEATSHEETLINUX CHEATSHEETDOCKERFILE LINTERYAML VALIDATORCRON PARSERREGEX TESTER

Terraform Tutorial

Introduction to Terraform
Installing Terraform
The Terraform Core Workflow
Terraform Variables and Outputs
Terraform State and Backends
Terraform Modules
Terraform Data Sources
Terraform Count and for_each
Terraform Expressions and Functions
Terraform Dynamic Blocks
Terraform Workspaces
Terraform with AWS
Terraform Provisioners
Terraform CI/CD Pipeline
Terraform Security

Writing Terraform Modules

PreviousPrev
Next

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 (this is 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

  1. Create modules for repeated patterns, not for everything.
  2. Keep module interfaces focused and documented.
  3. Use main.tf, variables.tf, and outputs.tf as a clean baseline structure.
  4. Add validation for important inputs.
  5. Return useful outputs intentionally.
  6. Use local modules first, then move to Git or Registry distribution when the module matures.
  7. Test modules with a real example and terraform plan.
  8. 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_name
  • environment
  • vpc_cidr
  • public_subnet_cidrs
  • private_subnet_cidrs
  • availability_zones

Possible outputs

  • vpc_id
  • public_subnet_ids
  • private_subnet_ids
  • internet_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

Exercise

Question 1: Module Structure

Which set of files is the most common starting structure for a reusable Terraform module?

Exercise

Question 2: When to Create a Module

Which situation is the strongest signal that you should create a Terraform module?

Exercise

Question 3: Testing Modules

What is a practical first step for testing a new Terraform module?

PreviousPrev
Next

Continue Learning

Terraform State Commands

Learn the most important Terraform state and import commands, including terraform state list, show, mv, rm, import, and refresh-related workflows.

10 min read·Medium

Terraform Modules

Learn the big picture of Terraform modules, how to use existing modules safely, and how to write your own reusable modules for cleaner infrastructure code.

5 min read·Medium

Using Terraform Modules

Learn how to use Terraform modules from local paths and the Terraform Registry, pass inputs, pin versions, run terraform init, and consume module outputs.

12 min read·Medium

Explore Related Topics

AW

AWS Tutorials

Provision AWS infrastructure with Terraform

CI

CI/CD Tutorials

Run terraform plan/apply in CI pipelines

Try the Tool

YAML Validator

Validate Terraform and Kubernetes YAML configs before applying.

On This Page

Why Write Your Own Terraform Modules?When Should You Create a Module?1. You repeat the same pattern2. You want standard defaults3. You want a simpler interface4. You want DRY infrastructure codeA Typical Terraform Module Structure`main.tf`, Resources & Logic`variables.tf`, Input Definitions`outputs.tf`, Module File Structure`README.md`Building a Simple Module: Example S3 Bucket Module`variables.tf``main.tf``outputs.tf`, Defining Module OutputsCalling a Local ModuleModule Inputs: Designing a Good InterfaceGood input design exampleAvoid giant interfaces when possibleModule Outputs: Returning Useful ResultsNested ModulesWhen nested modules helpWhen nested modules become a problemModule Sources: Local Path, Git, and Terraform Registry1. Local path2. Git source3. Terraform RegistryPublishing to the Terraform Registry (Briefly)Testing Modules with `terraform plan`Example test structureWriting Modules for ReadabilityHelpful habitsExample of using locals inside a moduleVersioning Your Own ModulesCommon Beginner Mistakes When Writing ModulesCreating modules too earlyExposing too many inputsForgetting outputsNo documentationNo test exampleBest Practices for Writing Terraform ModulesComplete Example: Reusable VPC Module PatternPossible inputsPossible outputsFinal ThoughtsKnowledge Check