Study guide

10.3 Using Modules in Configuration

HashiCorp Certified: Terraform Associate 004

Track your progress as you read

Finished sections get a check mark.

1 Introduction & Exam Overview

1.1 Current Exam Facts 1.2 Format, Delivery, Retakes, and Recertification 1.3 004 Changes, Terraform 1.12, and Study Plan

2 Infrastructure as Code with Terraform

2.1 What Infrastructure as Code Is 2.2 Advantages of IaC Patterns 2.3 Multi-Cloud, Hybrid, and Service-Agnostic Workflows

3 Terraform Fundamentals: Providers & State

3.1 Install and Version Terraform Providers 3.2 How Terraform Uses Providers 3.3 Configuration with Multiple Providers 3.4 How Terraform Uses and Manages State

4 Core Terraform Workflow: Init & Validate

4.1 The Terraform Write–Plan–Apply Workflow 4.2 Initialize a Working Directory 4.3 Validate a Configuration

5 Plan, Apply, Destroy, and Formatting

5.1 Generate and Review an Execution Plan 5.2 Apply Changes to Infrastructure 5.3 Destroy Terraform-Managed Infrastructure 5.4 Formatting and Style Adjustments

6 Resources, Data Sources, and References

6.1 Resource Blocks vs Data Blocks 6.2 Resource Attributes and Cross-Resource References 6.3 Resource Addressing and Interpolation Patterns

7 Variables, Outputs, and Complex Types

7.1 Input Variables, tfvars, and Precedence 7.2 Output Values 7.3 Complex Types: list, map, object, tuple, set

8 Dynamic Configuration and Dependencies

8.1 Expressions, count, for_each, and dynamic Blocks 8.2 Built-in Functions 8.3 Dependencies, depends_on, and Lifecycle Rules

9 Conditions, Secrets, and Sensitive Data

9.1 Custom Conditions: validation, precondition, postcondition, check 9.2 Sensitive Data Handling 9.3 Vault, Ephemeral Values, and Write-Only Arguments

10 Terraform Modules

10.1 How Terraform Sources Modules 10.2 Variable Scope within Modules 10.3 Using Modules in Configuration 10.4 Managing Module Versions

11 Terraform State Management

11.1 The Local Backend 11.2 State Locking 11.3 Remote State and the backend Block 11.4 Resource Drift and State Operations

12 Maintain Infrastructure with Terraform

12.1 Import Existing Infrastructure 12.2 Inspect State with the CLI 12.3 Verbose Logging and Diagnostics

13 HCP Terraform

13.1 Create Infrastructure with HCP Terraform 13.2 Collaboration and Governance Features 13.3 Workspaces and Projects 13.4 HCP Terraform Integrations

10.3 Using Modules in Configuration

Key Takeaways Explain This Topic AI

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:

  • 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]
  • }

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