10.3 Using Modules in Configuration

Key Takeaways

  • Call a child with module "NAME" { source = ... } and pass inputs as arguments that match the child's variable names
  • Read exported data as module.NAME.OUTPUT; terraform output lists only root-module outputs unless you re-export
  • count and for_each on a module block create multiple instances and are mutually exclusive; addresses become module.NAME[i] or module.NAME["key"]
  • Default unaliased provider configurations are inherited; aliased providers must be passed with the providers meta-argument map
  • Compose modules in the root by wiring one module's outputs into another module's inputs; the root module is the working directory
Last updated: August 2026

10.3 Using Modules in Configuration

Quick Answer: Write module "NAME" { source = ... }, pass each child input as an argument, and read results as module.NAME.OUTPUT. count / for_each instantiate the module more than once. The providers map passes provider configurations; aliased providers are never inherited. The working directory is the root module. Compose siblings in that root by wiring outputs into inputs.

Objective 5c on Terraform Associate (004) is: use modules in configuration. Product version is Terraform 1.12. Official pages: Modules overview, Use modules in your configuration, the module block reference, Providers within modules, and Module composition.

The module block

Terraform commands read .tf files in one directory — the current working directory, unless you pass -chdir. That directory is the root module. A module block is how the root (or another child) loads a second directory of configuration and manages its resources in the same state.

module "vpc" {
  source = "./modules/vpc"

  cidr_block  = var.vpc_cidr
  environment = var.environment
}

module "app" {
  source = "./modules/app"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
  name       = "checkout"
}

Required meta-argument: source (section 10.1). Optional built-in arguments on Terraform 1.12:

ArgumentRole
versionRegistry-only constraint (section 10.4)
countInteger number of identical instances; mutually exclusive with for_each
for_eachMap or set of strings; one instance per key
providersMap of child provider names to provider configurations in the caller
depends_onExplicit dependency when no input already references the upstream object

Everything else in the block is a module input. Each name must match a variable in the child. After you write or change a module block, run terraform init so the child is installed, then plan / apply as usual.

There is no resource "module" block and no path = argument. The label after module is a local name you choose. It becomes the module.vpc prefix in expressions and in state.

Passing inputs and reading outputs

Pass inputs as argument = expression. The expression is evaluated in the caller. That is how var.vpc_cidr (a root variable) becomes var.cidr_block (a child input) after you write cidr_block = var.vpc_cidr.

Read outputs as module.<LABEL>.<OUTPUT>. Use them in resources, in other module arguments, or in root outputs:

resource "aws_route53_record" "app" {
  zone_id = var.zone_id
  name    = "app"
  type    = "A"
  ttl     = 60
  records = [module.app.public_ip]
}

output "vpc_id" {
  description = "Re-export so terraform output and remote state can see it"
  value       = module.vpc.vpc_id
}

terraform output lists root outputs only. If you never re-export module.vpc.vpc_id, the CLI will not print it. Implicit dependencies work the same way they do for resources: because module.app reads module.vpc.vpc_id, Terraform creates the VPC module first. Add depends_on only when the child's inputs do not already reference the object that must finish first.

count and for_each on modules

Terraform 0.13 made count, for_each, and depends_on legal on module blocks. On 1.12 they are ordinary tools.

module "bucket" {
  source = "./modules/bucket"
  count  = 3

  name = "logs-${count.index}"
}

module "env" {
  source   = "./modules/env"
  for_each = toset(["dev", "staging", "prod"])

  environment = each.key
  cidr_block  = var.cidrs[each.key]
}
  • count produces module.bucket[0], module.bucket[1], module.bucket[2]. Read an output as module.bucket[0].bucket_name or module.bucket[*].bucket_name.
  • for_each over a set produces module.env["dev"]. Read module.env["prod"].vpc_id. Over a map, each.key is the map key and each.value is the value.
  • You cannot set count and for_each on the same block.
  • A child that contains its own provider blocks is not compatible with count, for_each, or depends_on. Shared modules must receive providers from the caller.

Use count when instances are interchangeable and you only care about how many. Use for_each when each instance has a stable key you do not want to shift when you add or remove an item.

The providers meta-argument

Provider configurations (region, credentials, aliases) are global to the configuration and are declared in the root module. Provider requirements (required_providers with source and version) are declared in every module that uses the provider.

Default, unaliased provider configurations are inherited. If the root has provider "aws" { region = "us-west-1" } and the child declares resource "aws_s3_bucket", that bucket uses us-west-1 with no extra wiring.

Aliased configurations are never inherited. Pass them with providers:

provider "aws" {
  region = "us-west-1"
}

provider "aws" {
  alias  = "usw2"
  region = "us-west-2"
}

module "dr" {
  source = "./modules/replica"

  providers = {
    aws = aws.usw2
  }
}

The map keys are provider names as the child expects them. The values are configurations in the caller. A child that needs two AWS regions declares configuration_aliases = [aws.src, aws.dst] in its required_providers block, and the caller writes providers = { aws.src = aws.usw1, aws.dst = aws.usw2 }.

Setting providers overrides default inheritance for the providers you list. You cannot pass a different provider configuration to each instance of a count / for_each module; the association is static. Use separate module blocks when instances need different aliases.

Reusable modules should not contain provider blocks. That legacy pattern still exists for old modules that do not use count / for_each / depends_on, and 004 can ask why a modern child must not do it.

Module composition and the root working directory

HashiCorp's recommended style is a flat tree: the root calls small children and wires them together. Do not hide a VPC inside the app module so every app creates its own network. Invert the dependency — the app module receives vpc_id and subnet_ids:

module "network" {
  source          = "./modules/aws-network"
  base_cidr_block = "10.0.0.0/8"
}

module "consul_cluster" {
  source     = "./modules/aws-consul-cluster"
  vpc_id     = module.network.vpc_id
  subnet_ids = module.network.subnet_ids
}

That is module composition. The same consul_cluster module can later take those IDs from data "aws_vpc" instead of from module.network without changing its internals. The root module — the working directory — is the composer.

When you cd into that directory and run terraform apply, Terraform uses that root, that state, and that set of module calls. Nested children are not separate applies. There is no terraform apply modules/vpc in the normal workflow; you apply the root and Terraform walks the tree.

Removing a module block and applying destroys the child's resources (unless you replace the block with a removed block and lifecycle { destroy = false }, Terraform 1.7+). Refactoring a root resource into a child without replacement uses a moved block so the address changes from aws_vpc.this to module.vpc.aws_vpc.this.

004 traps for using modules

  • module "name" is the block type. resource "module" is not.
  • Inputs are arguments. Outputs are module.name.output. Do not swap them.
  • count and for_each cannot share a block.
  • Aliased providers must be passed; only the default configuration is inherited.
  • A child with its own provider blocks cannot use count, for_each, or depends_on.
  • The working directory is the root. You apply the root, not each child folder.
Loading diagram...
Flat module composition in the root working directory
Test Your Knowledge

Which snippet correctly calls a local child, passes an input, and lets the parent read a child output named subnet_ids on Terraform 1.12?

A
B
C
D
Test Your Knowledge

You need three copies of a network module with similar configuration. Which approach is valid on Terraform 1.12?

A
B
C
D
Test Your Knowledge

The root module defines provider "aws" { alias = "usw2" region = "us-west-2" }. How do you make a child use that configuration?

A
B
C
D