# Advanced Module Practices: Versioning and Composition

> Chapter 11 of the Courseiva HASHICORP-TERRAFORM curriculum — https://courseiva.com/learn/hashicorp-terraform/advanced-module-practices

**Official objective:** 5.2 — Describe best practices for module versioning, composition, and sharing via the Terraform Registry.

## Introduction

Module versioning and composition are the practices that allow you to reliably reuse and combine Terraform configuration building-blocks (called modules) across different projects. This matters for your TF-003 exam because the most efficient way to manage infrastructure at scale is to avoid reinventing the wheel, and the exam heavily tests your knowledge of how to select, use, and share these pre-built components safely.

## The Cookbook Recipe Version and Meal Planning Analogy

Your kitchen, a Sunday afternoon. You are baking a complex three-layer cake for a friend's birthday. You find the recipe online, but it's from a blog that has been updated many times. The first time you baked this cake, you used the '1.0' version of the recipe, which called for buttermilk. You saved that version because it was perfect.

Now, the blog has a '2.0' version. It swaps buttermilk for yoghurt to make the cake lighter. You don't want a lighter cake; you want the dense, rich version you made before. So, instead of clicking the latest post, you specifically look for the archived '1.0' recipe page. You are performing version pinning.

Meanwhile, your friend is also making a side dish, a basil cream. You don't need to invent that recipe yourself. You pull it from a different, well-known cooking website. That basil cream recipe is its own independent component. You combine it with your cake recipe to create a full meal. This is module composition. You didn't write the basil cream instruction from scratch; you reused it. You are also sharing your complete meal plan, including the specific versions of both recipes used, so your friend can replicate the exact taste. If they just grab the latest 'basil cream' recipe without checking the version, their cream might be too runny, and the whole meal is ruined. Versioning and composition ensure your entire meal—your infrastructure—is predictable and reproducible every single time.

## Core explanation

In Terraform, a module is a container for multiple resources that are used together. Think of it as a pre-packaged set of instructions to create a specific piece of infrastructure, like a virtual network or a database server. Just like software code, these modules change over time—they get bug fixes, new features, or breaking changes.

Versioning is the system of assigning unique identifiers (like '1.0.0' or '2.1.3') to each iteration of a module. This is called Semantic Versioning (SemVer). The format is MAJOR.MINOR.PATCH. A MAJOR version change (1.0.0 to 2.0.0) means the module has changes that will break your existing configuration if you update without adjusting your code. A MINOR version change (1.0.0 to 1.1.0) adds new features but should not break existing functionality. A PATCH version change (1.0.0 to 1.0.1) is for backwards-compatible bug fixes.

When you use a module in your Terraform code, you specify a source (where to find the module, e.g., the Terraform Registry, GitHub, or a local path) and a version constraint. A version constraint is a rule you write in your code to tell Terraform which versions of the module you are willing to accept. Common constraints include:

- '>= 1.0.0' (any version at or above 1.0.0)
- '~> 1.0.0' (only PATCH-level updates within the 1.0.x series, e.g., 1.0.1 is allowed, but 1.1.0 is not)
- '= 1.0.0' (exact version lock, you only want that specific version)

If you do not specify a version constraint, Terraform will fetch the latest version available from the source, which can lead to unexpected changes in your infrastructure when the module author publishes a new version. This is called 'version drift' and is a common cause of production incidents.

Composition is the practice of building larger, more complex infrastructure by combining multiple smaller, focused modules. Instead of writing one gigantic module that provisions a web server, a database, and a load balancer all tangled together, you create separate modules for each component: a 'web-server' module, a 'database' module, and a 'load-balancer' module. You then create a 'root module' (your main Terraform configuration) that calls these three modules, passing in the correct configuration values (called input variables) for each. This is the composition pattern.

The Terraform Registry is a public, centralised repository (like an app store for modules) where module authors publish their work. Modules on the Registry have enforced versioning and documentation. When a module is published to the Registry, the Registry enforces Semantic Versioning rules. You can browse the Registry to find a module that already solves your problem, rather than writing everything from scratch. This is the sharing aspect of the objective.

Best practices for using modules include:

- Always specify a version constraint for any module you source from a remote location like the Registry or a Git repository.
- Prefer using the '= MAJOR.MINOR.PATCH' constraint in production to lock to an exact version, and only upgrade after testing in a development environment.
- Keep modules small and focused on a single responsibility (one module for a virtual machine, another for the network security group).
- Use the Terraform Registry as your primary source for well-maintained, community-vetted modules.
- Design your own modules to accept input variables for customisation and to output useful information (like the IP address of the created server) for other parts of your composition to consume.

## Real-world context

An IT professional at a mid-sized e-commerce company manages the cloud infrastructure using Terraform. The company has a standard deployment that includes a web server, a database, and a content delivery network (CDN).

Instead of writing the entire configuration for every new product launch, the team has created three modules:

- The 'web-server' module (version 2.1.0) - provisions an Amazon EC2 instance with the company's custom application image.
- The 'database' module (version 3.0.0) - creates an RDS MySQL database instance with specified storage and backup settings.
- The 'cdn' module (version 1.5.2) - sets up a CloudFront distribution in front of the web server.

Step-by-step, what they do:

1. When the team starts a new project, they create a new root module (a new Terraform directory).
2. In the main.tf file, they write three `module` blocks, each referencing the respective module from the Terraform Registry or their private registry.
3. Crucially, they pin the versions in the root module: `source = "terraform-aws-modules/vpc/aws"` and `version = "= 3.0.0"`. This prevents Terraform from accidentally downloading version 4.0.0 which might have breaking changes.
4. They pass input variables (like subnet IDs, database password from a secrets manager, and the application version) from the root module into each child module.
5. They also use output values from one module as inputs to another. For example, the output of the web-server module (its security group ID) is passed into the load-balancer module to allow traffic.

When the database team publishes an update to the database module (version 3.0.1, a bug fix), the infrastructure team does not immediately see that change in their production deployment because they have explicitly pinned to version = 3.0.0 in their root module. They first create a test environment, update the version constraint to '~> 3.0.0' to pull in the patch, run `terraform plan` to verify nothing breaks, and only then commit the new constraint to production.

This practice prevents the 'works on my machine' problem across teams. The website team can update the web-server module independently of the database team, and the root module composition glues everything together. If a junior engineer accidentally types 'version = ">= 1.0.0"', a senior engineer would catch this in code review because it violates the team's policy of using exact version pins for production modules.

## Exam focus

The TF-003 exam tests specific, concrete knowledge about module versioning and composition. It does not ask vague 'best practice' questions. It tests mechanisms.

Key areas you must know:

- The syntax for specifying a module source and version. You will be given a module block and asked to select the correct version constraint syntax. The trap is that version constraints use a specific operator ('=', '~>', '>=', '<=', '!=') and the exam will test whether you know the difference between '~> 1.0' and '>= 1.0, < 2.0'.
- The format of version constraints in the 'required_version' setting for Terraform itself versus the 'version' argument within a module block. These are two different configuration points.
- The behaviour of the '~>' operator. The exam loves this. You must know that '~> 1.0.0' allows only patch updates up to 1.0.x, while '~> 1.0' allows minor updates up to 1.x.
- The concept of a 'module composition' and how outputs from one module feed into inputs of another. The exam will present a diagram or a config with two module blocks and ask if the configuration is valid (e.g., can module A's output be used as module B's input?). The correct answer is yes, by using the module A output reference.
- The Terraform Registry's role. Know that the Registry enforces Semantic Versioning, provides documentation, and is where public modules are shared.

Common traps the exam sets:

- Asking which version constraint allows the most flexibility vs. the most strict. You must know '= 1.0.0' is strictest and '>= 1.0.0' is most flexible.
- Offering a version constraint with a missing '=' sign. For example, 'version = "1.0.0"' without an operator is equivalent to '= 1.0.0' (exact match). The exam may present 'version = 1.0.0' (without quotes) as a distracter.
- Testing whether you know that you can define modules locally (using a relative path) without a version constraint. Version constraints are only required for remote sources.

Definitions to memorise exactly:

- Module composition: the practice of building infrastructure by combining multiple modules.
- Version pinning: specifying an exact version to prevent unwanted updates.
- Version constraint: a string used to restrict which module versions are acceptable.

## Step by step

1. **Define the module source** — In your root module's main.tf, you write a 'module' block and set the 'source' argument to the location of the module, e.g., 'terraform-aws-modules/vpc/aws' for a Registry module, or 'git::https://github.com/example/vpc.git' for a Git repository.
2. **Add a version constraint** — Inside the same 'module' block, add a 'version' argument with a constraint string like '"= 3.0.0"' to lock to a specific version. This prevents Terraform from updating the module without your explicit change to the constraint.
3. **Configure input variables** — Look up the module's documentation to see what input variables it accepts (e.g., 'cidr_block', 'region'). Then, in your root module, assign values to those variables inside the 'module' block, often using references to other resources or variables you've defined.
4. **Use outputs for composition** — After defining the module, you can reference its output values elsewhere in your root module. For example, if the VPC module outputs 'vpc_id', you can use 'module.vpc.vpc_id' as an input to another module, like a security group module.
5. **Run terraform init** — Execute 'terraform init' to download the specified version of the module to your local .terraform directory. Terraform will verify the version constraint against what is available in the source. If a matching version is found, it caches it. If not, it gives an error.
6. **Test and promote version changes** — When you want to upgrade a module, you change the version constraint in your development environment, run 'terraform init', and run 'terraform plan' to see the changes. Once validated, you commit the new version constraint to your production configuration, ensuring all team members use the same version.

## Comparisons

### Version Pinning vs Version Range

**Version Pinning:**
- Specifies an exact version like '= 2.0.0'
- Changes only when you manually edit the constraint
- Safer for production as it prevents unexpected upgrades

**Version Range:**
- Specifies a range like '>= 2.0.0, < 3.0.0'
- Automatically accepts patch releases within the range
- Risk of unexpected changes if a new patch introduces a bug

### Module (Registry) vs Module (Local Path)

**Module (Registry):**
- Sourced from a remote repository like Terraform Registry
- Requires a version constraint for reproducibility
- Supports semantic versioning and automatic documentation

**Module (Local Path):**
- Sourced from a relative or absolute file path on your machine
- Does not require a version constraint (path is the version)
- No built-in version checking; changes are immediate

### Module Composition vs Monolithic Module

**Module Composition:**
- Multiple small, focused modules combined
- Each module can be versioned independently
- Easier to test and swap component versions

**Monolithic Module:**
- Single large module that does everything
- One version for the entire system
- Difficult to upgrade just one part without affecting others

### Terraform Registry vs GitHub as Module Source

**Terraform Registry:**
- Requires version constraints to work properly
- Registry enforces semantic versioning conventions
- More discoverable through search and documentation

**GitHub as Module Source:**
- Can use Git branches and tags as versions
- No automatic enforcement of versioning conventions
- Requires manual documentation and discovery

## Diagram

_Shows how a root module composes three separate modules, with outputs from one module feeding into inputs of another._

```mermaid
flowchart TD
    A[Root Module] --> B[VPC Module v3.0.0]
    A --> C[EC2 Module v2.1.0]
    A --> D[RDS Module v1.5.2]
    B --> E[Output: vpc_id]
    C --> F[Output: instance_ip]
    E --> G[Input for Security Group Module]
    G --> H[Security Group Module v1.0.0]
```

## Common misconceptions

- **Misconception:** If I don't specify a version, Terraform will use the version I already have cached locally. **Reality:** If you do not specify a version, Terraform will fetch the latest version from the source every time you run 'terraform init', which can cause infrastructure changes without your consent. (Beginners assume that Terraform is 'lazy' and will reuse what is in the local cache. In reality, without a version constraint, Terraform treats the source as a mutable tag that it checks for updates.)
- **Misconception:** Semantic versioning in Terraform modules works the same as in libraries like npm or RubyGems. **Reality:** While it follows the same MAJOR.MINOR.PATCH pattern, Terraform's module system is less strict about enforcing SemVer promises. A module author could publish a 'minor' release that actually breaks something, and Terraform will not detect it. (People assume a universal standard. But Terraform Registry does not automatically validate that a minor release is backwards-compatible with your provider configurations. The author can make mistakes.)
- **Misconception:** Composition means I should put all my module calls into one big file. **Reality:** Composition means logically separating modules into focused components and then calling them from a root module. You can split your root module across multiple files (main.tf, outputs.tf, variables.tf) for organisation, but the 'composition' is the design pattern, not the file structure. (Beginners confuse the concept of 'composition' with the physical organisation of Terraform files. They think putting everything in one file is 'composing' them, but composition is about the architectural relationship of independent modules.)
- **Misconception:** You cannot use modules from the Terraform Registry without configuring a remote backend. **Reality:** Modules from the Registry can be used with any backend (including local). The Registry is just a source of module code, not a state store. (The exams conflate 'Terraform Registry' with 'remote state' in their study materials, leading beginners to think they are coupled. They are completely independent concepts.)

## Key takeaways

- Always specify a version constraint when using a module from a remote source to prevent unexpected changes during 'terraform init'.
- Semantic versioning (MAJOR.MINOR.PATCH) is the standard versioning scheme for modules in the Terraform Registry.
- The '~>' operator (pessimistic constraint) allows only the rightmost part of the version to increment, e.g., '~> 1.0' allows 1.x, but '~> 1.0.0' allows only 1.0.x.
- Module composition involves creating small, single-purpose modules and combining them in a root module to build complex infrastructure.
- Outputs from one module can be referenced as inputs to another module, enabling data flow between components.
- The Terraform Registry enforces semantic versioning and provides a public platform for sharing and discovering modules.
- Version pinning (using an exact version like '= 1.0.0') is the safest practice for production environments.
- You can compose modules from different sources (Registry, GitHub, local paths) in the same root module.

## FAQ

**What happens if I don't specify a version in a module block?**

Terraform will fetch the latest version from the source during 'terraform init'. This can introduce breaking changes unexpectedly. It is considered a bad practice.

**Can I use a module from the Terraform Registry without creating an account?**

Yes, the Terraform Registry is public and does not require an account to use modules. You only need an account if you want to publish your own modules.

**How do I update a module to a new version?**

Change the version constraint in your 'module' block to match the new version (e.g., from '"= 1.0.0"' to '"= 1.1.0"'), then run 'terraform init -upgrade' to force Terraform to download the new version.

**Can I reference the output of one module in another module in the same configuration?**

Yes. Use the syntax 'module.module_name.output_name' to pass the output value to another module's input variable or to a resource attribute.

**What is the difference between a module and a resource in Terraform?**

A resource is a single infrastructure object (like an EC2 instance). A module is a container that groups multiple resources together and can be reused across configurations. You call a module, but you declare a resource.

**Does the Terraform Registry support private modules?**

Yes, the Terraform Registry (both public and private versions) supports private modules that require authentication to access. The private registry is often used within organisations.

## Check your understanding

1. **Which version constraint would you use to allow only patch-level updates to version 1.0.x (e.g., 1.0.1, 1.0.2) but not version 1.1.0?**

   Answer: Use '~> 1.0.0'. The tilde-arrow operator with three digits allows only the rightmost digit to increment.

2. **What is the primary purpose of a version constraint in a module block?**

   Answer: To control which specific versions (or ranges of versions) of a module Terraform is allowed to download and use, ensuring reproducibility.

3. **Can you use a module from the Terraform Registry without specifying a version?**

   Answer: Technically yes, but it is bad practice because Terraform will always fetch the latest version, risking breaking changes.

4. **What is the difference between 'module composition' and 'module nesting'?**

   Answer: Composition refers to calling independent modules from a root module to build infrastructure. Nesting refers to having a module that itself calls another module inside its own code. Composition is about architecture; nesting is about code structure within a module.

5. **If module A outputs a value 'vpc_id', how do you reference it in module B?**

   Answer: Use the expression 'module.A.vpc_id' in module B's input variable assignment.

---

Interactive version with quiz and diagrams: https://courseiva.com/learn/hashicorp-terraform/advanced-module-practices
