Designing Your First Resuable Module
A module becomes useful when it turns repeated or complicated infrastructure into a component with a clear interface. Instead of scattering the same implementation throughout a root configuration, you can put the implementation behind inputs and outputs and let the caller work with the component at a higher level.
In this chapter, you will build a small module from scratch so you can see exactly how values travel into and out of it.
Start With A Small Configuration
Begin with a simple Terraform configuration that creates a local file.
Create this directory structure:
terraform-project/
βββ main.tfPut the following in main.tf:
terraform {
required_version = ">= 1.6.0"
}
resource "local_file" "web_server" {
filename = "${path.module}/web-server.txt"
content = "web server configuration"
}
output "filename" {
value = local_file.web_server.filename
}This is intentionally small. The important part is not the file resource itself; it is the shape of the configuration.
The root module directly owns the resource and directly exposes its output.
Now imagine that this “web server” component grows. Perhaps it eventually needs several resources, configuration values, naming rules, and outputs. Keeping all of that in the root module may make the root configuration increasingly difficult to understand.
That is a good point to consider a module.
Create The Module
Change the project structure to:
terraform-project/
βββ main.tf
βββ variables.tf
βββ outputs.tf
βββ modules/
βββ web-server/
βββ main.tf
βββ variables.tf
βββ outputs.tfThe web-server directory is now a child module.
The root module will call it. The child module will own the resource.
Create modules/web-server/main.tf:
resource "local_file" "web_server" {
filename = "${path.root}/${var.filename}"
content = "web server: ${var.name}"
}Create modules/web-server/variables.tf:
variable "name" {
description = "Name of the web server."
type = string
}
variable "filename" {
description = "File created by the module."
type = string
}Create modules/web-server/outputs.tf:
output "filename" {
description = "Path of the file created by the module."
value = local_file.web_server.filename
}There are now three distinct responsibilities inside the child module:
variables.tfdefines what the module accepts.main.tfimplements the module.outputs.tfdefines what the module exposes.
The filenames are conventional. What matters is the configuration they contain.
Call The Module From The Root Module
Now replace the root main.tf with:
module "web_server" {
source = "./modules/web-server"
name = "frontend"
filename = "web-server.txt"
}The root module is now saying:
Use the module located at
./modules/web-server, give it the namefrontend, and ask it to createweb-server.txt.
The root module does not directly contain the local_file resource anymore.
That resource belongs to the child module.
The root variables.tf is not needed for this first example, so you can leave it out. Likewise, the root outputs.tf will be added in the next step.
The complete structure is:
terraform-project/
βββ main.tf
βββ modules/
βββ web-server/
βββ main.tf
βββ variables.tf
βββ outputs.tfFollow The Value Through The Module
This is the most important part of the example.
The value "frontend" starts in the root module:
module "web_server" {
source = "./modules/web-server"
name = "frontend"
}Terraform passes it to the child module’s variable:
variable "name" {
type = string
}The child module uses that variable:
content = "web server: ${var.name}"The resource is therefore created with:
web server: frontendThe value flow looks like this:
Root Module
β
β name = "frontend"
βΌ
Module Input
β
β var.name
βΌ
ResourceNow consider the other direction.
The resource creates a filename:
local_file.web_server.filenameThe module exposes it:
output "filename" {
value = local_file.web_server.filename
}The flow becomes:
Resource
β
β filename
βΌ
Module Output
β
βΌ
Root ModuleTogether:
root input
β
module input
β
resource
β
module output
β
root outputThat is the basic module interface.
Expose The Module Output
A child module’s output is available to its caller through the module instance.
Add outputs.tf to the root module:
output "web_server_file" {
description = "File created by the web-server module."
value = module.web_server.filename
}Now the root module is exposing the child module’s output as its own output.
The complete project is:
terraform-project/
βββ main.tf
βββ outputs.tf
βββ modules/
βββ web-server/
βββ main.tf
βββ variables.tf
βββ outputs.tfThe root module knows that the child module provides a value called filename.
It does not need to reach inside the child module and reference:
local_file.web_server.filenameThat resource belongs to the child module and is not directly addressable from the root as though it were part of the root module.
The module output is the boundary.
Initialize And Apply The Module
From the root directory, run:
terraform initTerraform discovers the local module and initializes the configuration.
Then inspect the plan:
terraform planYou should see Terraform planning one local_file resource.
Apply it:
terraform applyApprove the plan when prompted.
You should now have:
terraform-project/
βββ main.tf
βββ outputs.tf
βββ web-server.txt
βββ modules/
βββ web-server/
βββ main.tf
βββ variables.tf
βββ outputs.tfThe generated file should contain:
web server: frontendYou can also inspect the root output:
terraform outputThe important result is not the exact filename. It is that the root module provided an input to the child module and received an output back.
Understand The Responsibilities
At this point, the boundary should look like this:
Root Module
βββββββββββββββββ
β β
β name β
β filename β
β β
βββββββββ¬ββββββββ
β
βΌ
βββββββββββββββββββ
β web-server β
β module β
β β
β implementation β
β β
ββββββββββ¬βββββββββ
β
βΌ
filenameThe root module decides which module to use and what values to pass.
The child module owns the resource implementation.
The child module returns only the values that it intentionally exposes.
That separation is the beginning of reusable infrastructure design.
Why Not Expose Everything?
Suppose the module internally creates several resources.
You could expose every possible internal value:
output "resource_a_id" {
value = ...
}
output "resource_a_name" {
value = ...
}
output "resource_b_id" {
value = ...
}
output "resource_b_name" {
value = ...
}But more outputs do not automatically make a module better.
A module should expose values that are useful to its callers.
For example, if callers need a server’s filename, expose the filename. If they do not need to know about an internal implementation resource, there is little reason to expose that resource merely because it has an attribute that could be returned.
The same principle applies to inputs.
A module should accept meaningful decisions rather than every possible argument of every resource it creates.
A Small Module Is Enough
You do not need a dozen resources to justify learning modules.
The small example deliberately isolates the important mechanism:
Caller
β
β input
βΌ
Child Module
β
β implementation
βΌ
Output
β
βΌ
CallerReal modules can contain many resources, but the interface works the same way.
The more important your module becomes, the more important it is that this interface remains understandable.
Clean Up
When you are finished experimenting, remove the resources created by the example:
terraform destroyConfirm the destruction when prompted. Because web-server.txt was created and managed by Terraform, it will be deleted. Also, outputs and resources tracking inside terraform.tfstate will be empty:
{
"version": 4,
"terraform_version": "1.14.3",
"serial": 4,
"lineage": "ea9fb22f-fd92-4ea3-a396-f1c17fb6e51b",
"outputs": {},
"resources": [],
"check_results": null
}Then you can remove the generated file and Terraform’s working directory if you no longer need them:
rm -rf .terraform
rm -f .terraform.lock.hclThe module source itself remains in modules/web-server/.
The key idea to carry forward is simple:
Module variables and outputs form the interface between the caller and the implementation.
Once you are comfortable with that boundary, you can start designing richer interfaces and composing one module with another.