Skip to content
Infrastructure as Code iac modules 4 min read

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 source can pull a breaking release on the next terraform init -upgrade and 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-argumentValue typeInstance addressBest for
countnumbermodule.name[0]Identical, order-based copies
for_eachmap or set of stringsmodule.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 version constraint (or a Git ?ref= tag) so upgrades are deliberate.
  • Run terraform init after adding, removing, or re-sourcing any module.
  • Pass values explicitly through inputs rather than relying on shared global state or hidden defaults.
  • Prefer for_each over count for 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.
Last updated June 14, 2026
Was this helpful?