Calling Modules
A module is only useful once you call it. In Terraform, you instantiate a module with the module block: you point it at a source, pin a version, pass input variables as arguments, and consume its outputs through the module.<name>.<output> reference syntax. Calling modules is how you turn reusable building blocks into concrete infrastructure, and it is the foundation of composing larger systems from smaller, tested pieces. Everything here works identically on OpenTofu, which shares the same configuration language.
The module block
A module block has a local name (the label after module) and at minimum a source. The local name is how you refer to the module elsewhere in the configuration — it does not have to match the directory or registry name.
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
}
When source points to a local path it must begin with ./ or ../. After adding or changing a source, you must run terraform init so Terraform can download and install the module into .terraform/modules.
terraform init
Output:
Initializing modules...
- network in modules/network
Initializing the backend...
Initializing provider plugins...
Terraform has been successfully initialized!
Versioning remote modules
For modules from a registry (the public Terraform Registry, a private registry, or a Git/HTTP source that supports it), always set version. The version argument accepts the same constraint operators as provider versions and is only valid for registry sources — for Git sources you pin a tag with ?ref=.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
}
Pin every remote module with a version constraint. An unpinned
sourcecan pull a breaking release on the nextterraform init -upgradeand silently change your plan.
Passing inputs
Every argument in a module block other than the reserved meta-arguments (source, version, count, for_each, providers, depends_on) maps to a variable declared in the module. Terraform validates the value against the variable’s type and applies its default when you omit it. Required variables (those without a default) must be supplied or the plan fails.
variable "environment" {
type = string
default = "dev"
}
module "app_bucket" {
source = "./modules/s3-bucket"
bucket_name = "devcraftly-${var.environment}-assets"
versioning = true
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
Reading module outputs
A child module exposes values through output blocks. From the calling configuration you read them with module.<local_name>.<output_name>. This is the primary way to wire modules together — the output of one becomes the input of another.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
}
resource "aws_instance" "web" {
ami = "ami-0c7217cdde317cfec"
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnets[0]
}
output "vpc_id" {
value = module.vpc.vpc_id
}
Note that module.vpc.public_subnets[0] also creates an implicit dependency: Terraform will create the VPC and its subnets before the instance.
count and for_each on modules
Since Terraform 0.13, the count and for_each meta-arguments work on module blocks, letting you instantiate a module multiple times. Prefer for_each over count when instances are keyed by a stable identifier, because adding or removing a key won’t force re-creation of unrelated instances.
locals {
buckets = {
logs = { versioning = false }
assets = { versioning = true }
}
}
module "bucket" {
source = "./modules/s3-bucket"
for_each = local.buckets
bucket_name = "devcraftly-${each.key}"
versioning = each.value.versioning
}
output "bucket_arns" {
value = { for k, m in module.bucket : k => m.arn }
}
When a module uses count, the instances are addressed as module.bucket[0]; with for_each they are addressed by key, as module.bucket["assets"].
| Meta-argument | Value type | Instance address | Best for |
|---|---|---|---|
count | number | module.name[0] | Identical, order-based copies |
for_each | map or set of strings | module.name["key"] | Keyed, independently managed instances |
Worked example output
Running terraform apply against the for_each example above produces two bucket instances and a single keyed output map.
Output:
module.bucket["assets"].aws_s3_bucket.this: Creating...
module.bucket["logs"].aws_s3_bucket.this: Creating...
module.bucket["logs"].aws_s3_bucket.this: Creation complete after 2s
module.bucket["assets"].aws_s3_bucket.this: Creation complete after 2s
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
bucket_arns = {
"assets" = "arn:aws:s3:::devcraftly-assets"
"logs" = "arn:aws:s3:::devcraftly-logs"
}
Best Practices
- Always pin remote modules with a
versionconstraint (or a Git?ref=tag) so upgrades are deliberate. - Run
terraform initafter adding, removing, or re-sourcing any module. - Pass values explicitly through inputs rather than relying on shared global state or hidden defaults.
- Prefer
for_eachovercountfor module instances keyed by a meaningful identifier. - Wire modules together via
module.<name>.<output>to create implicit, correct dependency ordering. - Keep the calling configuration thin — let the module own the resource detail and expose only the inputs and outputs callers need.