# Creating and Using Terraform Modules

> Chapter 10 of the Courseiva HASHICORP-TERRAFORM curriculum — https://courseiva.com/learn/hashicorp-terraform/modules-and-reuse

**Official objective:** 5.1 — Explain the concept of modules and how to create, call, and publish modules for code reuse.

## Introduction

If you cannot reuse your infrastructure code, you will waste hours copying, pasting, and debugging identical configurations across multiple environments — and almost certainly make a mistake that takes down a service. Terraform modules solve this by giving you a way to package, share, and call infrastructure logic just like you would reuse a function in a programming language. For the TF-003 exam, understanding modules is not just a topic — it is the backbone of how real Terraform users organise their work, and the exam will test whether you can identify, create, and call them correctly.

## The Favourite Cookbook Recipe Module Analogy

You decide to bake 3 dozen chocolate chip cookies for a friend's party. You find a favourite recipe online that includes the exact list of ingredients (flour, sugar, eggs, chocolate chips) and a set of instructions (mix dry ingredients, cream butter and sugar, fold in chips, bake at 350°F for 12 minutes). You save this recipe as a note on your phone called "Perfect Cookie Module".

Now imagine your friend asks for 3 dozen cookies, but also asks for 3 dozen oatmeal raisin cookies and 3 dozen peanut butter cookies. Instead of writing a brand new set of instructions for each type from scratch, you call your "Perfect Cookie Module" three times. Each time you call it, you give it different parameters — for one call you swap white sugar for brown sugar and add oats and raisins, for another you replace half the butter with peanut butter. The module's core structure (mixing, baking temperatures, times) stays the same, but the ingredients change based on the inputs you supply.

After the party, your cousin asks for the same recipes. Because you saved your module as a reusable block, you can share the file with her. She calls it on her own kitchen and gets identical results. This is exactly what Terraform modules do: they package reusable infrastructure code (like a recipe) that you can call with different values to create different environments (dev, test, production) without rewriting everything.

## Core explanation

A Terraform module is a self-contained collection of Terraform configuration files (.tf files) that are stored together in a single directory. The purpose of a module is to group related resources (like a web server, a database, and a load balancer) into a reusable unit that you can call from other configurations.

Think of a module as a "blueprint" for a set of infrastructure. Instead of writing the same 50 lines of code every time you need a web server, you write a module once and then "call" it wherever you need it. This saves time, reduces errors, and makes your code easier to update — change the module once, and every place that calls it gets the update automatically.

Every Terraform configuration is itself a module — this is called the "root module". The root module is the set of .tf files in your main working directory. When you start writing Terraform, you are already working in a root module. What we are discussing here is the concept of "child modules" — modules that you create and then call from your root module.

Let us break down the anatomy of a module. A typical module directory contains three key files: main.tf (the primary resource definitions), variables.tf (input variables that allow you to customise the module behaviour), and outputs.tf (values that the module returns to the caller, like the IP address of a server). Optionally, a module may also contain a versions.tf for provider and Terraform version constraints.

To use a module, you add a "module" block in your root configuration. The block specifies the source (where to find the module — a local path, a Git repository, or the Terraform Registry) and provides values for the module's input variables. For example:

module "web_server" {
  source  = "./modules/webserver"
  instance_type = "t2.micro"
  region        = "us-east-1"
}

When Terraform processes this block, it loads the module from the given source and treats the resources inside it as part of the overall configuration. The module's outputs become available as attributes on the module reference — for example, module.web_server.public_ip.

Why do modules matter for a beginner? Two reasons. First, they enforce a separation of concerns. Your production database config is managed separately from your development environment — each calls the same module but passes different input values. Second, modules allow teams to share standardised infrastructure patterns. Your organisation can create an "encrypted S3 bucket" module that includes all the security best practices, and every team uses it instead of writing their own.

Modules can be published to the public Terraform Registry, which is a repository of pre-built modules maintained by HashiCorp, partners, and the community. For example, there is an official AWS VPC module that creates a full virtual network with subnets, route tables, and internet gateways — all with about ten lines of code in your root module.

Versioning is a critical concept when using modules from the registry. When you specify a source like "hashicorp/consul/aws", you can add a version argument (e.g., version = "~> 1.0.0") to ensure you are using a specific release. This prevents unexpected changes when the module author updates the module.

Input variables in a module have types (string, number, list, map) and can have default values. If no default is given, the variable is mandatory and must be provided when calling the module. Outputs, on the other hand, are optional but are the only way for calling code to access data from inside the module — you cannot directly reference a resource inside a child module from the root module unless it is exposed via an output.

Modules can also call other modules, creating a tree-like structure. This is called "composition" and is a best practice for complex infrastructure. For instance, a "networking" module might call separate sub-modules for VPC, subnets, and security groups. However, for the TF-003 exam, you mostly need to understand the parent-child relationship at one level — how to create a module, how to call it, and what the source and version arguments mean.

Finally, modules must be initialised before use. When you add a new module to your configuration, you must run "terraform init" to download the module source code into the .terraform directory. If you change the version of a module, you need to re-run "terraform init". This is a common exam trap — they will ask what happens when you add a new module block without running init.

## Real-world context

An IT professional working at a mid-size e-commerce company is tasked with standardising the deployment of web applications. There are three teams: frontend, backend, and data-engineering. Each team currently writes its own Terraform configuration for basic infrastructure like load balancers, auto-scaling groups, and databases. The problem: configurations are inconsistent, security groups are misconfigured in two environments, and a developer accidentally deleted a production database because they used the wrong Terraform workspace.

The senior engineer decides to create a single reusable "web-app" module that includes everything needed to run a typical web application.

Here is how they proceed:

- Step 1: Create a new directory called "modules/web-app" with main.tf, variables.tf, and outputs.tf.
- Step 2: In main.tf, they define an AWS launch template, an auto-scaling group, an application load balancer, a security group that only allows HTTP and HTTPS traffic from the load balancer, and an RDS database instance. All resource names include a common prefix variable to avoid conflicts.
- Step 3: In variables.tf, they declare inputs like "environment_name", "instance_type", "db_instance_class", "vpc_id", and "subnet_ids". Each has a sensible default or is marked as required.
- Step 4: In outputs.tf, they expose "load_balancer_dns_name" and "rds_endpoint" as outputs.
- Step 5: The engineer publishes this module to the company's private Git repository (or a private Terraform Registry). They tag the repository with version "1.0.0".
- Step 6: Each team now creates a root module that calls the "web-app" module three times — once for dev, once for staging, once for prod. They pass different input values for instance_type (t2.micro for dev, t3.large for prod) and database size.
- Step 7: The engineer sets up a CI/CD pipeline that runs "terraform plan" and "terraform apply" automatically. When a new version of the module is released, the pipeline updates the version constraint in each team's root module.

The result: deployments that were taking two days now take ten minutes. Security misconfigurations drop to near zero because the module enforces the correct rules. The IT professional no longer has to audit each team's code individually — they just review the module once.

For the exam, you need to recognise that this workflow is exactly what modules enable. The exam will ask you to identify the correct module block syntax, understand that variables are passed as arguments in the module block, and know that you must initialise after adding a module. You may also be asked about the difference between local and remote modules, and when to use the "count" meta-argument inside a module to create multiple instances of the module itself.

## Exam focus

The TF-003 exam tests your understanding of modules in several specific ways. You must be able to identify the correct syntax for calling a module, distinguish between types of module sources, and understand the lifecycle of modules (init, plan, apply). Here is exactly what you will see:

Questions about module block syntax:
- The exam will give you a module block with a missing argument or incorrect assignment. For example, they might show "source = ./modules/vpc" without quotes around the path (which is invalid — paths must be in quotes). They love to test that "source" is the only required argument in a module block.
- They might show a module block that uses "resource" instead of "module". Remember: modules are declared with the "module" keyword, not "resource".

Questions about module sources:
- You will be asked which source types are valid. The valid ones are: local path (e.g., "./modules/webserver"), Terraform Registry (e.g., "hashicorp/consul/aws"), Git repository (e.g., "git@github.com:org/repo.git"), HTTP URL, and S3 bucket (using S3 protocol).
- A common trap: they ask if a module can be sourced from a specific version control branch. Yes — you can append "?ref=develop" to a Git URL. But if they ask if the Terraform Registry version argument accepts a branch name, the answer is no — the "version" argument only accepts semantic version constraints (e.g., ">= 1.0, < 2.0").

Questions about module variables and outputs:
- The exam will test that input variables in a module are set by passing arguments inside the module block, not by writing a separate variable block in the root module.
- They will test that outputs from a module are accessed using module.module_name.output_name syntax.
- A trap question: they might show a root module trying to directly reference a resource inside a child module (e.g., "aws_instance.web.id") without an output. This will fail — you must expose it as an output from the module.

Questions about initialisation:
- After adding a new module block to your configuration, you MUST run "terraform init" before "terraform plan" or "terraform apply". The exam will ask: "What command must you run after adding a module block referencing a new source?" Answer: terraform init.
- If you change the version of a module in the version argument, you must re-run terraform init.

Questions about module versioning (from the Terraform Registry):
- The exam loves asking about version constraints. For example, "~> 1.2.0" means only versions >= 1.2.0 and < 1.3.0. "~> 1.0" means >= 1.0.0 and < 2.0.0.
- They might ask whether modules from a local path can have a version argument — they cannot. The version argument is only valid for modules sourced from a registry or a remote source that supports versioning.

Questions about best practices and code reuse:
- The exam will test that modules help you avoid duplication, manage dependencies, and enforce consistency.
- They might ask: "What is the primary benefit of using modules?" Answer: code reuse and consistent infrastructure patterns.

Memorise these exact concepts:
- "module" keyword syntax
- Required arguments: source
- Optional arguments: version, providers, count, for_each, depends_on
- Valid source types: local, registry, Git, HTTP, S3
- Initialisation requirement: terraform init
- Output access: module.<module_name>.<output_name>

## Step by step

1. **Create the module directory** — Create a new directory (e.g., 'modules/my-module') inside your project. This directory will hold the module's .tf files. Keeping modules in a 'modules' folder is a common convention that helps organise your codebase.
2. **Write the module code** — Inside the module directory, create at least main.tf (resources), variables.tf (input variables), and optionally outputs.tf (return values). Define the resources you want the module to manage using the same Terraform syntax you would use in the root module.
3. **Define input variables** — In variables.tf, declare each input with a 'variable' block. Specify a type (string, number, list, map) and optionally a default value. Variables without a default are mandatory and must be provided when calling the module. This customisation is what makes the module reusable across different environments.
4. **Expose outputs** — In outputs.tf, declare each value you want the caller to be able to read. Common outputs include resource IDs, IP addresses, and DNS names. Without outputs, nothing from the module is visible outside it.
5. **Call the module from the root configuration** — In your root main.tf, add a module block: 'module "my_module" { source = "./modules/my-module" ... }'. Provide values for any mandatory variables. Terraform will load the module and include its resources in the configuration.
6. **Run terraform init** — Execute 'terraform init' to download the module source code into the .terraform directory. This step also installs any required providers referenced inside the module. You must re-run this command if you change the source or version of a module.
7. **Run terraform plan and terraform apply** — Use 'terraform plan' to see what resources the module will create. Then run 'terraform apply' to provision them. The module's resources are managed as part of your state file, just like resources defined directly in the root module.

## Comparisons

### Local Module vs Remote Module (Registry)

**Local Module:**
- Sourced from a local file path (e.g., ./modules/vpc)
- No version constraint needed — changes are immediate
- No terraform init download needed for the module itself

**Remote Module (Registry):**
- Sourced from a registry URL (e.g., hashicorp/vpc/aws)
- Must specify a version constraint (e.g., version = '~> 1.0')
- Requires terraform init to download the module

### Root Module vs Child Module

**Root Module:**
- The set of .tf files in your main working directory
- All resources are directly managed in the state
- Cannot be called from other configurations

**Child Module:**
- A set of .tf files in a separate directory
- Resources are encapsulated — outputs must be used to access data
- Called from the root module or another child module

### Module Input Variable vs Module Output Value

**Module Input Variable:**
- Defined in variables.tf using a 'variable' block
- Passed as an argument inside the calling module block
- Used to customise module behaviour or resource properties

**Module Output Value:**
- Defined in outputs.tf using an 'output' block
- Accessed from the root module using module.module_name.output_name
- Used to return resource attributes to the caller

### count Meta-Argument on Modules vs for_each Meta-Argument on Modules

**count Meta-Argument on Modules:**
- Creates N identical instances of the module
- Uses a single number to specify count
- Each instance is indexed by its count index (0, 1, 2...)

**for_each Meta-Argument on Modules:**
- Creates instances based on keys from a map or set
- Allows non-sequential naming and different values per instance
- Each instance is indexed by its key value from the input map

## Diagram

_A root module calls two child modules — one from a local path and one from the Terraform Registry — showing how inputs connect to the module and outputs are returned._

```mermaid
flowchart TD
    R[Root Module] --> M1[Call Module 'web-app']
    R --> M2[Call Module 'database']
    M1 --> S1[Source: ./modules/web-app]
    M2 --> S2[Source: hashicorp/consul/aws]
    S1 --> V1[Input Variables: environment, instance_type]
    S2 --> V2[Input Variables: datacenter, token]
    V1 --> O1[Outputs: lb_dns, rds_endpoint]
    V2 --> O2[Outputs: consul_url, consul_token]
```

## Common misconceptions

- **Misconception:** A module is exactly the same as a Terraform provider — you declare them both in a similar way. **Reality:** A provider is a plugin that interacts with an API (like AWS or Azure), while a module is a reusable collection of resources and data sources that you call from your configuration. Providers go in "required_providers" blocks; modules go in "module" blocks. (Both providers and modules are installed with "terraform init", and beginners see the term "module" used loosely online.)
- **Misconception:** You cannot use Terraform Registry modules unless you pay for a commercial license. **Reality:** The Terraform Registry is free and open to everyone. Anyone can publish or use modules from the registry without paying HashiCorp. (HashiCorp sells Terraform Cloud and Enterprise, which include private registries and collaboration features — but the public registry is free.)
- **Misconception:** Every resource you define must be inside a module for your Terraform code to be considered 'good'. **Reality:** Modules are an optional organisational tool. Simple configurations do not need custom modules. You can write resources directly in the root module. The exam tests that you know how to create and call modules, not that you must always use them. (Online tutorials often over-emphasise modules as a best practice, leading beginners to think they are mandatory.)
- **Misconception:** When you change a module's source code, all environments that use it are automatically updated on the next terraform apply. **Reality:** If the module is sourced from a local path, yes, changes take effect immediately. But if sourced from a registry or Git, you must update the version constraint or ref and re-run terraform init to pull the new version. (Beginners do not understand the difference between mutable local references and versioned remote sources.)
- **Misconception:** Module outputs are optional and not really needed — you can just reference resources directly inside the module from outside. **Reality:** Resources inside a child module are not directly accessible from the root module. You must declare outputs in the module to expose any values. This is a core rule of Terraform's module encapsulation. (In many programming languages, internal variables of a function are not visible outside it; the same principle applies here, but beginners expect direct access.)
- **Misconception:** A module block cannot use the count or for_each meta-arguments. **Reality:** Count and for_each are valid on module blocks since Terraform 0.13. You can create multiple instances of the same module with different input values. (Older Terraform versions (pre-0.13) did not support count on modules, so outdated resources or tutorials may still say it is not possible.)

## Key takeaways

- A module is a collection of Terraform configuration files in one directory that you can call from another configuration using a module block.
- The source argument is the only required argument in a module block; it tells Terraform where to find the module.
- Running terraform init is mandatory after adding a new module or changing its version — without it, Terraform cannot load the module.
- Input variables in a module are defined in variables.tf and set by passing arguments inside the calling module block.
- Outputs in a module are the only way to expose resource attributes from the module to the calling configuration.
- Module version constraints (like '~> 1.0.0') are only valid for modules sourced from the Terraform Registry or other versioned remote sources, not for local paths.
- Every Terraform configuration is itself a module — the root module — and custom child modules allow you to reuse infrastructure patterns across environments and teams.
- You can use count or for_each on a module block to create multiple instances of the same module with different input values.

## FAQ

**What is the difference between a Terraform module and a Terraform provider?**

A provider is a plugin that connects Terraform to a specific service API (like AWS). A module is a reusable set of resource definitions that you write or download. Providers are declared in the required_providers block; modules are called with the module block.

**Can I use a Terraform module that is stored on my local machine?**

Yes. Use a local file path as the source argument, like source = "./modules/my-module". The path can be relative or absolute. No terraform init is needed if the path points to a local directory — only if you later switch to a remote source.

**Does every Terraform module need a variables.tf file?**

No. A module can have zero input variables if it is completely self-contained. However, most useful modules define at least a few variables to allow customisation (like region or instance size).

**How do I pass the same variable to a module that I already defined in the root module?**

You reference the root variable inside the module block. For example: module "web" { source = "..." instance_type = var.root_instance_type }. The module does not automatically inherit root variables — you must pass them explicitly.

**What happens if I change a module's source after terraform init?**

You must run terraform init again to download the new version. Running terraform plan or apply without re-initialising will use the old cached module code and may give errors.

**Can a module call another module?**

Yes. Modules can call other modules, creating a nested structure. This is called module composition. For example, a networking module might call separate sub-modules for VPC, subnets, and security groups. The exam expects you to be aware of this concept.

**Is it mandatory to publish modules to the Terraform Registry to use them?**

No. You can keep modules in a local directory, a Git repository, an S3 bucket, or any HTTP server. The Terraform Registry is simply a public repository for sharing modules with the community.

## Check your understanding

1. **What is the only required argument in a Terraform module block?**

   Answer: The 'source' argument. It tells Terraform where to find the module (local path, registry URL, Git repository, etc.).

2. **After adding a new module block to your configuration, which command must you run before terraform plan?**

   Answer: You must run 'terraform init' to download the module source code into the .terraform directory.

3. **How do you access a value from a module called 'web' that has an output named 'public_ip'?**

   Answer: You reference it as 'module.web.public_ip' in your root configuration.

4. **Can you use the version argument when sourcing a module from a local file path?**

   Answer: No. The version argument is only valid for modules sourced from a registry or other versioned remote source.

5. **What is the primary purpose of Terraform modules?**

   Answer: To package and reuse infrastructure code across different configurations and environments, promoting consistency and reducing duplication.

---

Interactive version with quiz and diagrams: https://courseiva.com/learn/hashicorp-terraform/modules-and-reuse
