Terraform Quickstart: Part 7 – Terraform Modules Guide
How to Use and Create Terraform Modules with Best Practices
Introduction
As your Terraform projects grow, your configuration files can get longer and harder to manage. You might end up repeating the same code across different projects or teams. This is inefficient and increases the risk of mistakes.
Terraform solves this problem elegantly through modules.
Terraform has two types of modules:
Custom modules you write yourself.
Community modules from the Terraform Registry.
In this chapter, you’ll learn:
How to create your own modules.
How to use community modules.
Simple best practices for working with modules.
Let’s get started!
Use-Case Example: Reusable GitHub Repository Setup
Terraform modules work like reusable building blocks. Instead of copying the same infrastructure code across projects, you define it once in a module and reuse it.
Let’s say your team needs the same GitHub repository setup for every new project:
A repository with specific settings.
Branch protection rules.
A
CODEOWNERS
file.
Manually repeating this setup wastes time and increases the chance of errors. With a module, you write the configuration once, share it across projects, and keep things consistent. If you ever need to update the setup, you do it in one place.
Creating Your First Module
A typical module directory looks like:
github-repo-module/
├── main.tf
├── variables.tf
├── outputs.tf
First, define the main.tf
:
# Main resource definition
resource "github_repository" "repo" {
name = var.repository_name
description = var.description
visibility = var.visibility
has_issues = var.has_issues
has_wiki = var.has_wiki
has_projects = var.has_projects
auto_init = var.auto_init
gitignore_template = var.gitignore_template
license_template = var.license_template
topics = var.topics
allow_merge_commit = var.allow_merge_commit
allow_squash_merge = var.allow_squash_merge
allow_rebase_merge = var.allow_rebase_merge
delete_branch_on_merge = var.delete_branch_on_merge
}
# Optional branch protection
resource "github_branch_protection" "protection" {
count = var.enable_branch_protection ? 1 : 0
repository_id = github_repository.repo.node_id
pattern = var.default_branch
enforce_admins = var.enforce_admins
require_signed_commits = var.require_signed_commits
required_status_checks {
strict = var.status_checks_strict
contexts = var.required_status_checks
}
required_pull_request_reviews {
dismiss_stale_reviews = true
required_approving_review_count = var.required_approving_review_count
}
}
Define your variable in the variables.tf
:
variable "repository_name" {
description = "The name of the GitHub repository"
type = string
}
variable "description" {
description = "Description of the repository"
type = string
default = ""
}
variable "visibility" {
description = "Repository visibility: public or private"
type = string
default = "private"
validation {
condition = contains(["public", "private"], var.visibility)
error_message = "Valid values for visibility are: public, private"
}
}
variable "has_issues" {
description = "Enable GitHub issues"
type = bool
default = true
}
variable "has_wiki" {
description = "Enable GitHub wiki"
type = bool
default = false
}
variable "has_projects" {
description = "Enable GitHub projects"
type = bool
default = false
}
variable "auto_init" {
description = "Automatically initialize the repository with README"
type = bool
default = true
}
variable "gitignore_template" {
description = "Template for .gitignore file (e.g., 'Node', 'Python')"
type = string
default = null
}
variable "license_template" {
description = "Template for license (e.g., 'mit', 'apache-2.0')"
type = string
default = null
}
variable "topics" {
description = "Repository topics/tags"
type = list(string)
default = []
}
variable "default_branch" {
description = "Default branch of the repository"
type = string
default = "main"
}
variable "allow_merge_commit" {
description = "Allow merge commits"
type = bool
default = true
}
variable "allow_squash_merge" {
description = "Allow squash merges"
type = bool
default = true
}
variable "allow_rebase_merge" {
description = "Allow rebase merges"
type = bool
default = true
}
variable "delete_branch_on_merge" {
description = "Delete branch on merge"
type = bool
default = true
}
variable "enable_branch_protection" {
description = "Enable branch protection rules"
type = bool
default = false
}
variable "enforce_admins" {
description = "Enforce branch protection for admins"
type = bool
default = false
}
variable "require_signed_commits" {
description = "Require signed commits"
type = bool
default = false
}
variable "status_checks_strict" {
description = "Require branches to be up to date before merging"
type = bool
default = true
}
variable "required_status_checks" {
description = "List of required status checks"
type = list(string)
default = []
}
variable "required_approving_review_count" {
description = "Number of approvals needed for PRs"
type = number
default = 1
}
Finally, print the URLs:
output "repository_name" {
description = "The name of the repository"
value = github_repository.repo.name
}
output "repository_full_name" {
description = "The full name of the repository (org/repo)"
value = github_repository.repo.full_name
}
output "repository_html_url" {
description = "URL to the repository on the web"
value = github_repository.repo.html_url
}
output "repository_ssh_clone_url" {
description = "SSH URL for cloning the repository"
value = github_repository.repo.ssh_clone_url
}
output "repository_http_clone_url" {
description = "HTTP URL for cloning the repository"
value = github_repository.repo.http_clone_url
}
Create a versions.tf
file:
terraform {
required_version = ">= 1.0.0"
required_providers {
github = {
source = "integrations/github"
version = ">= 4.0.0"
}
}
}
The versions.tf
file is not a required file — it's just a convention.
It usually contains:
The Terraform version constraint.
The required providers, including source and version.
This makes it easy to keep version settings separate from the rest of your config (like resources or modules) and ensures consistency across teams or projects.
Using Your Module
Open the main.tf
in your main Terraform project. For example, it could look like this:
terraform {
required_providers {
github = {
source = "integrations/github"
version = "~> 5.0"
}
}
}
provider "github" {
token = var.github_token
owner = var.github_owner
}
# Reference the local module using a relative path
module "my_repository" {
source = "./github-repo-module"
repository_name = "my-new-project"
description = "A project created with a local Terraform module"
visibility = "public"
topics = ["terraform", "example"]
}
# Outputs
output "repository_url" {
value = module.my_repository.repository_html_url
}
If you have pushed your module to a GitHub repo, you could have referenced the repo link using the source
argument. In our case, we’ll just test locally.
Create the variables.tf
file:
variable "github_token" {
description = "Personal Access Token for GitHub API"
type = string
sensitive = true
}
variable "github_owner" {
description = "Github user"
type = string
sensitive = false
}
Place your token into the terraform.tfvars
file:
github_token = "your token"
github_owner = "your username"
If you don’t want to create this file, you could also pass the values as an argument when you execute the terraform apply command:
$ terraform apply -var "github_token=YOUR_GITHUB_TOKEN"
Alternatively, you could export the value as an environment variable:
$ export TF_VAR_github_token=YOUR_GITHUB_TOKEN
$ terraform apply
In this example, we’ll be using the terraform.tfvars
file.
So far, you should have a folder structure like this:
.
├── github-repo-module
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├── main.tf
├── terraform.tfstate
├── terraform.tfvars
└── variables.tf
Execute the already familiar Terraform workflow:
$ terraform init
$ terraform plan
$ terraform apply
You should see your newly created repo in GitHub!
You can now reuse your infrastructure module with ease.
Using Modules from the Terraform Registry
Keep reading with a 7-day free trial
Subscribe to Curious Devs Corner to keep reading this post and get 7 days of free access to the full post archives.