Terraform Modules: Create and Use Local Modules

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.

NOTE
Run terraform init in the root module directory before your first plan. Examples use the built-in terraform_data resource so the focus stays on module behavior, not provider setup.

The finished layout looks like this:

text
terraform-modules/
├── main.tf
└── modules/
    └── application/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

What 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.

text
Root module
    └── Child module (application)
            ├── variables.tf   ← inputs
            ├── main.tf        ← resources
            └── outputs.tf     ← values exposed upward

A 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:

bash
mkdir -p ~/terraform-labs/terraform-modules/modules/application

Terraform 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:

hcl
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:

hcl
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:

hcl
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:

hcl
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 the module.application prefix in state
  • source — location of the child module. A local path beginning with ./ or ../ is resolved relative to the directory of the module containing this module block. In this lab the caller is the root module, so ./modules/application resolves from ~/terraform-labs/terraform-modules/.
  • Arguments (name = "web") — values passed into child variables

Initialize the root module so Terraform records the child module:

bash
cd ~/terraform-labs/terraform-modules && terraform init
output
Initializing 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:

bash
terraform validate
output
Success! The configuration is valid.

Plan shows the child resource address under the module prefix:

bash
terraform plan
output
# 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:

bash
terraform apply -auto-approve
output
module.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:

bash
terraform output application_name
output
"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.

text
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:

hcl
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:

bash
terraform init
output
Initializing modules...
- frontend in modules/application
- backend in modules/application

Skipping init after this edit produces Error: Module not installed for the new block names. Plan from the root directory:

bash
terraform plan

If you still have the single module.application instance in state, Terraform replaces it with two new instances:

output
# 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:

bash
terraform apply -auto-approve
output
Apply 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:

bash
terraform state list
output
module.backend.terraform_data.application
module.frontend.terraform_data.application

Same 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:

bash
terraform plan
output
# 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:

bash
terraform apply -auto-approve
output
Apply 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:

text
modules/application/
├── README.md
├── main.tf
├── variables.tf
└── outputs.tf

HashiCorp 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:

text
Root
├── networking module
├── application module
└── monitoring module

Prefer 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:

bash
terraform destroy -auto-approve
output
Destroy complete! Resources: 2 destroyed.

References


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.


Frequently Asked Questions

1. What is a Terraform module?

A module is a container for Terraform configuration. Every directory where you run terraform commands is a module. A configuration called by another module block is a child module. Modules group related resources and expose inputs through variables and results through outputs.

2. What is the difference between a root module and a child module?

The root module is the working directory where you run terraform init, plan, and apply. Child modules are called from module blocks in the root or in other modules. Only the root module is executed directly; child modules are invoked through module blocks.

3. Why do I need terraform init after adding a module?

terraform init installs child modules referenced by module blocks. Run it again after you add a child module or change its source so Terraform can install the newly referenced module package before plan or apply.

4. Can the root module reference a resource inside a child module directly?

No. The root module should read values the child exposes through output blocks, such as module.frontend.name. Resources inside a child module live in that module namespace and are not directly addressable from the parent.

5. Can I call the same local module more than once?

Yes. Each module block creates a separate module instance with its own state address prefix, such as module.frontend and module.backend, even when both use the same source directory.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)