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
10.3 Using Modules in Configuration
Quick Answer: Write
module "NAME" { source = ... }, pass each child input as an argument, and read results asmodule.NAME.OUTPUT.count/for_eachinstantiate the module more than once. Theprovidersmap 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:
| Argument | Role |
|---|---|
version | Registry-only constraint (section 10.4) |
count | Integer number of identical instances; mutually exclusive with for_each |
for_each | Map or set of strings; one instance per key |
providers | Map of child provider names to provider configurations in the caller |
depends_on | Explicit 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]
}
countproducesmodule.bucket[0],module.bucket[1],module.bucket[2]. Read an output asmodule.bucket[0].bucket_nameormodule.bucket[*].bucket_name.for_eachover a set producesmodule.env["dev"]. Readmodule.env["prod"].vpc_id. Over a map,each.keyis the map key andeach.valueis the value.- You cannot set
countandfor_eachon the same block. - A child that contains its own
providerblocks is not compatible withcount,for_each, ordepends_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. countandfor_eachcannot share a block.- Aliased providers must be passed; only the default configuration is inherited.
- A child with its own
providerblocks cannot usecount,for_each, ordepends_on. - The working directory is the root. You apply the root, not each child folder.
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?
You need three copies of a network module with similar configuration. Which approach is valid on Terraform 1.12?
The root module defines provider "aws" { alias = "usw2" region = "us-west-2" }. How do you make a child use that configuration?