Kahibaro
Discord Login Register

6.4.3 Modules

Why Terraform Modules Matter

In Terraform, modules are the main way to:

You’ve already seen basic Terraform in previous chapters. Here we focus on how to structure and use modules effectively, not on re‑explaining core Terraform syntax.

Think of a module as a function in a programming language: it has inputs, does some work, and returns outputs.

Basic Module Structure

A typical module is just a folder with some Terraform files:

Example layout:

.
├── main.tf          # root module
└── modules
    └── network
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Inside modules/network, you might have:

# modules/network/variables.tf
variable "vpc_cidr" {
  type        = string
  description = "CIDR block for the VPC"
}
variable "environment" {
  type        = string
  description = "Environment name (dev, staging, prod)"
}
# modules/network/main.tf
resource "aws_vpc" "this" {
  cidr_block = var.vpc_cidr
  tags = {
    Name        = "vpc-${var.environment}"
    Environment = var.environment
  }
}
# modules/network/outputs.tf
output "vpc_id" {
  value       = aws_vpc.this.id
  description = "The ID of the created VPC"
}

The module directory name (network) is not special to Terraform, but is used when you refer to it from the root module.

Calling a Local Module

From your root module:

# main.tf (root)
module "network" {
  source      = "./modules/network"
  vpc_cidr    = "10.0.0.0/16"
  environment = "dev"
}
output "network_vpc_id" {
  value = module.network.vpc_id
}

Key elements:

Module Inputs: Variables

Inside a module, inputs are defined using variable blocks:

variable "instance_type" {
  type        = string
  description = "EC2 instance type"
  default     = "t3.micro"
}

Good practices for variables in modules:

You pass values from the calling module:

module "web_server" {
  source        = "./modules/web_server"
  instance_type = "t3.small"
}

Complex Input Types

Modules often take complex structures:

variable "subnets" {
  type = map(object({
    cidr_block = string
    az         = string
  }))
}
# Caller
module "network" {
  source = "./modules/network"
  subnets = {
    public_1 = {
      cidr_block = "10.0.1.0/24"
      az         = "eu-west-1a"
    }
    public_2 = {
      cidr_block = "10.0.2.0/24"
      az         = "eu-west-1b"
    }
  }
}

This makes modules flexible and descriptive, while keeping logic inside the module.

Module Outputs

Outputs expose values from a module to its caller:

# modules/web_server/outputs.tf
output "public_ip" {
  value       = aws_instance.this.public_ip
  description = "Public IP of the web server"
}

The root module can then use:

output "web_server_ip" {
  value = module.web_server.public_ip
}

Outputs are useful for:

Local Modules vs Remote Modules

Terraform can use modules from:

  1. Local paths: ./modules/network
  2. Git repositories: e.g. GitHub, GitLab
  3. Terraform Registry: public or private
  4. Other sources (HTTP, Mercurial, etc.)

Local Modules

Simple for single‑repo projects:

module "db" {
  source   = "./modules/db"
  db_name  = "app"
  username = "appuser"
}

Pros:

Cons:

Git-Based Modules

Using a Git repo as the source:

module "network" {
  source = "git::https://github.com/example/infrastructure-modules.git//aws/network?ref=v1.2.0"
  vpc_cidr    = "10.1.0.0/16"
  environment = "prod"
}

Important details:

Pros:

Cons:

Terraform Registry Modules

Public modules use a simple source syntax:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
  name = "my-vpc"
  cidr = "10.0.0.0/16"
}

Format: NAMESPACE/NAME/PROVIDER

You can also run your own private registry (e.g. in Terraform Cloud/Enterprise) with the same syntax.

Designing Good Modules

Modules can be tiny wrappers or large systems. A few design principles help:

Clear Responsibility

A module should do one logical thing:

Avoid “god modules” that create everything at once unless you’re building a deliberate top‑level composition module.

Stable Interface

Treat module inputs/outputs as a public API:

Reasonable Defaults

Inputs for Policy, Not Implementation Detail

Example:

Expose what the caller cares about, not every small internal tweak.

Avoid Tight Coupling to a Single Use Case

If you design a module for prod-eu-west-1-only, it’s hard to reuse. Instead:

Example:

locals {
  name = "${var.project}-${var.environment}"
}

Then use local.name for resource names.

Composition: Modules Calling Modules

Modules can call other modules, creating a layered design:

# modules/app/main.tf
module "network" {
  source      = "../network"
  vpc_cidr    = var.vpc_cidr
  environment = var.environment
}
module "web_server" {
  source        = "../web_server"
  subnet_id     = module.network.public_subnet_id
  instance_type = var.instance_type
}

This pattern lets you:

Module Versioning and Locking

With remote modules (Git or registry), you should:

Examples:

# Registry module
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}
# Git module pinned to a tag
module "network" {
  source = "git::https://github.com/example/infrastructure-modules.git//aws/network?ref=v1.3.2"
}

Planning upgrades:

  1. Update version or ref.
  2. Run terraform plan.
  3. Review changes.
  4. Apply in non‑production first.

Testing and Validating Modules

For reusable modules (especially shared across teams):

A simple pattern:

Organizing Modules in a Repository

Common layouts:

Monorepo with Shared Modules

.
├── envs
│   ├── dev
│   │   └── main.tf
│   ├── staging
│   │   └── main.tf
│   └── prod
│       └── main.tf
└── modules
    ├── network
    ├── web_server
    └── database

Dedicated Module Repos

Each major module (or module set) in its own repository:

Consumers reference them with Git or a registry. Better for:

Common Pitfalls with Modules

When to Create a Module

Use a module when:

Keep things simple at first:

  1. Start with a flat root configuration.
  2. When duplication appears or things grow too large, extract a module.
  3. Gradually improve and version that module as it stabilizes.

This aligns with good DevOps practice: iterate, refactor, and automate only when it adds real value.

Views: 61

Comments

Please login to add a comment.

Don't have an account? Register now!