Terraform · lesson 7 of 17
Beginner
7- 1 Terraform Fundamentals (IaC)
- 2 Terraform Workflow (init → validate → plan → apply)
- 3 Terraform State (tfstate, remote state, locking)
- 4 Terraform Variables and Outputs
- 5 Providers and Resources (the building blocks of Terraform)
- 6 Terraform Locals: Computed Values Without Repetition
- 7 Terraform Modules (reuse, composition, and clean structure)
Intermediate
7- 8 Terraform count and for_each (repeat resources safely)
- 9 The lifecycle Block: prevent_destroy, create_before_destroy, ignore_changes
- 10 Data Sources and Dependencies (read existing infrastructure)
- 11 sensitive = true: What It Hides, and What It Doesn't
- 12 Remote State Safety (locking, drift, and recovery)
- 13 Workspaces and Environments (dev/stage/prod patterns)
- 14 Terraform CI/CD with GitHub Actions (plan/apply)
Advanced
3Also part of Terraform
4Terraform 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
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.