| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1 |
| Applies to | Any host with Terraform installed |
| Lab environment | Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu |
| Privilege | Normal user |
| Scope | Terraform modules — root vs child module, local module directory structure, module block with relative source, child variables and outputs, module output references, calling the same module twice, state addresses, init after module changes, composition overview, and common mistakes. Does not cover Registry discovery, Git or S3 module sources, version constraints, private Registry publishing, module testing frameworks, provider alias depth, or Professional-level module architecture. |
| Related guides | terraform init command Terraform variables Terraform output values Terraform resources Terraform Associate certification course |
You copied the same three-resource pattern into four environments last quarter. Each copy drifted slightly — one team renamed a variable, another hard-coded a tag. Modules exist to stop that cycle: group related configuration once, pass different inputs per call, and read results through outputs.
This walkthrough builds a tiny local application module with terraform_data, calls it from a root configuration, reuses it twice, and inspects how Terraform names module instances in state. Everything runs under ~/terraform-labs/terraform-modules/ with no cloud credentials.
terraform_data resource so the focus stays on module behavior, not provider setup.
The finished layout looks like this:
terraform-modules/
├── main.tf
└── modules/
└── application/
├── main.tf
├── variables.tf
└── outputs.tfWhat is a Terraform module?
Every Terraform configuration is a module. The directory where you run terraform plan and terraform apply is the root module. A configuration invoked through a module block is a child module.
Root module
│
└── Child module (application)
├── variables.tf ← inputs
├── main.tf ← resources
└── outputs.tf ← values exposed upwardA child module groups related resources into one reusable unit. The parent passes arguments that map to child variables; the parent reads module.<name>.<output> when it needs a result. Child resources stay inside the child namespace — the root does not reach into terraform_data.application directly.
Create a local Terraform module
Create the module directory tree:
mkdir -p ~/terraform-labs/terraform-modules/modules/applicationTerraform does not require main.tf, variables.tf, or outputs.tf by name — they are organization conventions teams use so inputs, resources, and outputs stay easy to find.
Write modules/application/variables.tf with the module input:
variable "name" {
type = string
}variables.tf declares what callers must supply. Without a default, name is required on every module block that uses this source.
Add the resource in modules/application/main.tf:
resource "terraform_data" "application" {
input = var.name
}main.tf holds the module's managed resources. Here one terraform_data instance stores the application name.
Expose a result in modules/application/outputs.tf:
output "name" {
value = terraform_data.application.output
}outputs.tf defines what the parent may read. On Terraform 1.15.8, terraform_data.application.output mirrors input after apply, so the parent can treat module.<instance>.name as the stored application name.
Call the module from the root configuration
In the root directory, create ~/terraform-labs/terraform-modules/main.tf:
module "application" {
source = "./modules/application"
name = "web"
}
output "application_name" {
value = module.application.name
}The module block has three parts you use on every call:
- Block label (
application) — local name for this instance; becomes themodule.applicationprefix in state source— location of the child module. A local path beginning with./or../is resolved relative to the directory of the module containing thismoduleblock. In this lab the caller is the root module, so./modules/applicationresolves from~/terraform-labs/terraform-modules/.- Arguments (
name = "web") — values passed into child variables
Initialize the root module so Terraform records the child module:
cd ~/terraform-labs/terraform-modules && terraform initInitializing modules...
- application in modules/application
Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
Terraform has been successfully initialized!The Initializing modules line confirms Terraform found ./modules/application. Run terraform init after adding a child module or changing its source. Terraform then installs any newly referenced module source before plan or apply.
Confirm the configuration is valid:
terraform validateSuccess! The configuration is valid.Plan shows the child resource address under the module prefix:
terraform plan# module.application.terraform_data.application will be created
+ resource "terraform_data" "application" {
+ id = (known after apply)
+ input = "web"
+ output = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ application_name = (known after apply)The address module.application.terraform_data.application is how Terraform distinguishes this instance from any other module call. Apply the plan:
terraform apply -auto-approvemodule.application.terraform_data.application: Creating...
module.application.terraform_data.application: Creation complete after 0s [id=b38a3ef3-...]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
application_name = "web"Read the root output directly:
terraform output application_name"web"The value flowed from root argument → child variable → child resource → child output → module.application.name.
Access resources and outputs from a child module
The root module should consume child results through outputs, not by referencing child resources directly. This boundary keeps the child free to rename or refactor internal resources without breaking callers.
root argument (name = "web")
↓
child variable (var.name)
↓
child resource (terraform_data.application)
↓
child output (output.name)
↓
root reference (module.application.name)Trying to write terraform_data.application.input in the root module fails — that resource lives inside the child namespace. The supported path is module.application.name, which reads the child's output "name" block.
Input and output design for production modules — defaults, validation, sensitive flags — is covered in the module inputs and outputs lesson. Here the goal is the wiring pattern.
Reuse the same module
Real configurations call the same module source more than once. Replace main.tf with two instances that share ./modules/application:
module "frontend" {
source = "./modules/application"
name = "frontend"
}
module "backend" {
source = "./modules/application"
name = "backend"
}
output "frontend_name" {
value = module.frontend.name
}
output "backend_name" {
value = module.backend.name
}You added new module block labels, so reinitialize before planning:
terraform initInitializing modules...
- frontend in modules/application
- backend in modules/applicationSkipping init after this edit produces Error: Module not installed for the new block names. Plan from the root directory:
terraform planIf you still have the single module.application instance in state, Terraform replaces it with two new instances:
# module.application.terraform_data.application will be destroyed
# (because terraform_data.application is not in configuration)
- resource "terraform_data" "application" { ... }
# module.backend.terraform_data.application will be created
+ resource "terraform_data" "application" {
+ input = "backend"
...
}
# module.frontend.terraform_data.application will be created
+ resource "terraform_data" "application" {
+ input = "frontend"
...
}
Plan: 2 to add, 0 to change, 1 to destroy.Apply the change:
terraform apply -auto-approveApply complete! Resources: 2 added, 0 changed, 1 destroyed.
Outputs:
backend_name = "backend"
frontend_name = "frontend"List state addresses to see how each instance is namespaced:
terraform state listmodule.backend.terraform_data.application
module.frontend.terraform_data.applicationSame source directory, two module block labels, two separate state addresses. That prefix is why modules scale — you define the pattern once and stamp out independent instances.
Change a module input and verify the plan
Edit the backend module argument from "backend" to "backend-v2" in main.tf. Plan again:
terraform plan# module.backend.terraform_data.application will be updated in-place
~ resource "terraform_data" "application" {
id = "0cb78b61-2ff4-391b-4a28-f95f3c11fc58"
~ input = "backend" -> "backend-v2"
~ output = "backend" -> (known after apply)
}
Plan: 0 to add, 1 to change, 0 to destroy.
Changes to Outputs:
~ backend_name = "backend" -> (known after apply)Only the backend instance changes; module.frontend is untouched. Apply when the plan looks right:
terraform apply -auto-approveApply complete! Resources: 0 added, 1 changed, 0 destroyed.
Outputs:
backend_name = "backend-v2"
frontend_name = "frontend"Changing a module argument updates that instance's resources through the normal plan/apply cycle.
Organize Terraform modules
For a reusable local module, teams commonly use:
modules/application/
├── README.md
├── main.tf
├── variables.tf
└── outputs.tfHashiCorp recommends main.tf, variables.tf, outputs.tf, and a README as the minimal reusable module layout. Only the root module of a configuration is technically required — a child module can be a single .tf file if you prefer.
Larger published modules sometimes add examples/ for copy-paste roots and split files by concern (versions.tf, providers.tf). For a small internal module, a flat trio of files under modules/<name>/ is enough.
At the repository level, composition often looks like:
Root
├── networking module
├── application module
└── monitoring modulePrefer relatively small modules with clear responsibilities over deep nesting that hides what is being created.
Module composition and common mistakes
| Mistake | What goes wrong | Fix |
|---|---|---|
Wrong relative source |
Terraform cannot find the expected module | Resolve ./ or ../ from the directory of the calling module, not automatically from the overall root configuration |
Skipping terraform init after module changes |
Module not installed on plan or apply |
Run terraform init after adding a child module or changing its source |
| Expecting child variables to see root variables automatically | Undeclared variable errors inside the child | Pass values explicitly: name = var.app_name in the module block |
| Referencing child resources from the root | Invalid reference to resource in child module | Use module.<label>.<output> only |
| Defining provider configuration blocks inside reusable child modules | Couples the module to a provider configuration; breaks patterns that use module count, for_each, or depends_on |
Configure providers in the root; let children inherit default configurations or pass aliases through providers. Each child should still declare its own required_providers requirements |
| Splitting a three-resource stack into six tiny modules | Harder to read than one file | Module when reuse or clear boundaries justify the indirection |
When you finish testing, remove the lab resources:
terraform destroy -auto-approveDestroy complete! Resources: 2 destroyed.References
- Modules overview — HashiCorp Terraform language docs
- Module blocks —
moduleblock syntax and arguments - Publish modules — recommended module structure and README
Summary
A Terraform module is simply a directory of configuration. The folder where you run commands is the root module; every module block you add creates a child module instance with its own variable scope and state address prefix.
You built a local application module with variables.tf, main.tf, and outputs.tf, called it from the root with source = "./modules/application", and read results through module.application.name. Calling the same source twice as module.frontend and module.backend produced two independent addresses in state — the practical payoff of modules over copy-paste.
The mistake I see most often is skipping terraform init after adding a child module or changing its source. Terraform must install newly referenced module sources before plan or apply. The second mistake is reaching into a child resource from the root; expose outputs instead and keep the boundary clean.
Next, learn how to pass richer inputs and outputs in the module input/output lesson, or revisit module sources and version constraints for how initialization installs local and remote module sources.

