Skip to main content
Terraform · lesson 7 of 17
Terraform beginner Lesson 7 of 17

Terraform Modules (reuse, composition, and clean structure)

Build a local Terraform module with inputs, validation and outputs, call it twice, then move it to for_each with moved blocks, all with real plan output.

By SkillByExample · Updated

terraform modules reuse

A module is a directory of .tf files. Its variables are its parameters, its outputs are its return values, and its resources stay private. Examples use Terraform 1.16.5 with hashicorp/random 3.9.1 and hashicorp/local 2.9.1.

A small module

modules/service/main.tf creates a suffix and a config file for one named service:

terraform {
  required_providers {
    random = { source = "hashicorp/random", version = "~> 3.9" }
    local  = { source = "hashicorp/local", version = "~> 2.9" }
  }
}

variable "name" {
  type = string
}

variable "port" {
  type = number

  validation {
    condition     = var.port >= 1024 && var.port <= 65535
    error_message = "port must be an unprivileged port (1024-65535)."
  }
}

resource "random_id" "suffix" {
  byte_length = 2
}

resource "local_file" "conf" {
  filename = "${path.root}/out/${var.name}.conf"
  content  = "name=${var.name}-${random_id.suffix.hex}\nport=${var.port}\n"
}

output "instance_name" {
  value = "${var.name}-${random_id.suffix.hex}"
}

path.root is the directory you ran terraform in, not the module’s own directory (path.module), so both services write into one top-level out/ directory.

Calling it twice

The root main.tf:

module "api" {
  source = "./modules/service"
  name   = "api"
  port   = 8080
}

module "worker" {
  source = "./modules/service"
  name   = "worker"
  port   = 9090
}

output "instances" {
  value = [module.api.instance_name, module.worker.instance_name]
}
$ terraform plan -no-color

Error: Module not installed

  on main.tf line 1:
   1: module "api" {

This module is not yet installed. Run "terraform init" to install all modules
required by this configuration.

Error: Module not installed

  on main.tf line 7:
   7: module "worker" {

This module is not yet installed. Run "terraform init" to install all modules
required by this configuration.
$ terraform init -no-color | sed -n 1,13p
Initializing the backend...

Initializing modules...
- api in modules/service
- worker in modules/service

Initializing provider plugins...
- Finding hashicorp/random versions matching "~> 3.9"...
- Finding hashicorp/local versions matching "~> 2.9"...
- Installing hashicorp/random v3.9.1...
- Installed hashicorp/random v3.9.1 (signed by HashiCorp)
- Installing hashicorp/local v2.9.1...
- Installed hashicorp/local v2.9.1 (signed by HashiCorp)
$ terraform apply -auto-approve -no-color | tail -16
module.worker.random_id.suffix: Creating...
module.api.random_id.suffix: Creation complete after 0s [id=4z8]
module.worker.random_id.suffix: Creation complete after 0s [id=Uag]
module.api.local_file.conf: Creating...
module.worker.local_file.conf: Creating...
module.api.local_file.conf: Creation complete after 0s [id=e225f5a9634bf6fbc200a0cf11ef6f4124b1b688]
module.worker.local_file.conf: Creation complete after 0s [id=99b2e4a8376d621cd1b5116a3e6908c34bcd5086]

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

instances = [
  "api-e33f",
  "worker-51a8",
]
$ terraform state list
module.api.local_file.conf
module.api.random_id.suffix
module.worker.local_file.conf
module.worker.random_id.suffix
$ cat out/api.conf
name=api-e33f
port=8080

Even a module on a local path has to be installed by init first. The root module never mentioned a provider, but init found the module’s required_providers and installed them for the whole configuration. Every resource address is prefixed with the module call (module.api.), so the same random_id.suffix can exist once per call without clashing.

The module’s outputs are its whole interface

Try reaching inside the module from the root:

output "api_suffix" {
  value = module.api.random_id.suffix.hex
}
$ terraform validate -no-color

Error: Unsupported attribute

  on main.tf line 18, in output "api_suffix":
  18:   value = module.api.random_id.suffix.hex
    ├────────────────
    │ module.api is object with 1 attribute "instance_name"

This object does not have an attribute named "random_id".

From outside, module.api is an object with exactly one attribute, its one output. Internal resources are private, so module authors can rename or restructure them without breaking callers. Anything a caller needs must be declared as an output.

Required inputs are enforced at the call

Delete the name = "api" line from the first module block:

$ terraform validate -no-color

Error: Missing required argument

  on main.tf line 1, in module "api":
   1: module "api" {

The argument "name" is required, but no definition was found.

A module variable with no default becomes a required argument of the module block, just as a required argument on a resource. The error points at the caller, which is where the fix goes.

A module from the Terraform Registry is called the same way, with a version constraint added. This snippet is configuration only, because it needs an AWS account:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "main"
  cidr = "10.0.0.0/16"
}

Practice

1. Set the api module’s port to 80. Where does the error point?

Solution
$ terraform plan -no-color

Error: Invalid value for variable

  on main.tf line 4, in module "api":
   4:   port   = 80
    ├────────────────
    │ var.port is 80

port must be an unprivileged port (1024-65535).

This was checked by the validation rule at modules/service/main.tf:15,3-13.

The error points at both places. It shows the caller’s line that passed the bad value, and the module’s own validation rule that rejected it. Validation inside a module protects every caller at once.

2. Add a config_path output to the module and expose the api’s path from the root.

Solution
# modules/service/main.tf
output "config_path" {
  value = local_file.conf.filename
}

# main.tf
output "api_config" {
  value = module.api.config_path
}
$ terraform apply -auto-approve -no-color | tail -8

Outputs:

api_config = "./out/api.conf"
instances = [
  "api-e33f",
  "worker-51a8",
]

Adding an output to the module is a backwards-compatible change to its interface, and the module’s resources are untouched.

3. Replace the two module blocks with one module "svc" using for_each, add a third service, and keep the existing two without recreating them.

Solution
module "svc" {
  source   = "./modules/service"
  for_each = { api = 8080, worker = 9090, billing = 9191 }

  name = each.key
  port = each.value
}

moved {
  from = module.api
  to   = module.svc["api"]
}

moved {
  from = module.worker
  to   = module.svc["worker"]
}

output "instances" {
  value = { for k, m in module.svc : k => m.instance_name }
}
$ terraform init -no-color | grep -A4 "modules"
Initializing modules...
- svc in modules/service

Initializing provider plugins...
- Reusing previous version of hashicorp/random from the dependency lock file
- Reusing previous version of hashicorp/local from the dependency lock file
--
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
$ terraform apply -auto-approve -no-color | grep -E '^  #|Plan:|Apply|^instances|^  "'
  # module.api.local_file.conf has moved to module.svc["api"].local_file.conf
  # module.api.random_id.suffix has moved to module.svc["api"].random_id.suffix
  # module.svc["billing"].local_file.conf will be created
  # module.svc["billing"].random_id.suffix will be created
  # module.worker.local_file.conf has moved to module.svc["worker"].local_file.conf
  # module.worker.random_id.suffix has moved to module.svc["worker"].random_id.suffix
Plan: 2 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
instances = {
  "api" = "api-e33f"
  "billing" = "billing-f5a6"
  "worker" = "worker-51a8"

The new module name needs another init. With the moved blocks, api and worker keep their suffixes (e33f, 51a8) and only billing is created. Without them, the plan would destroy four resources and create six.

Next: count and for_each on resources, and why the key you choose decides what a later edit destroys.

Frequently Asked Questions

What is a Terraform module?
Any directory of .tf files is a module. The directory you run terraform in is the root module; a module block with source = "./modules/service" calls another directory as a child module, passing its variables as arguments and reading its outputs as module.<name>.<output>.
Why can't I read a resource inside a module from the root?
A module's resources are private. From outside, module.api only has the attributes its output blocks declare, so module.api.random_id.suffix fails with 'Unsupported attribute'. Add an output inside the module for anything callers need; that output list is the module's public interface.
Do I need to run terraform init after adding a module?
Yes. Every new or renamed module block must be installed by terraform init before plan works, even for a local path, otherwise Terraform stops with 'Module not installed'. Registry and git modules are also downloaded at that point into .terraform/modules.
How do I create several copies of a module?
Put count or for_each on the module block, exactly as on a resource. Each instance gets an address like module.svc["api"], and resources inside are addressed module.svc["api"].random_id.suffix. When converting existing single module blocks, add moved blocks so the existing objects are kept instead of recreated.
How do I pin a module from the Terraform Registry?
Use source = "namespace/name/provider" plus version = "~> 5.0" on the module block. Local path sources have no version argument because they always use the code next to them; git sources pin with ?ref=v1.2.3 in the URL.