Skip to content
ioob.dev
Go back

Terraform Part 9 — Modules

· 5 min read
Terraform Series (9/15)
  1. Terraform Part 1 — What Is Terraform
  2. Terraform Part 2 — Installation and First Deploy
  3. Terraform Part 3 — HCL Syntax
  4. Terraform Part 4 — Variables and Outputs
  5. Terraform Part 5 — Providers
  6. Terraform Part 6 — Resources and Dependencies
  7. Terraform Part 7 — Data Sources and Import
  8. Terraform Part 8 — State Management
  9. Terraform Part 9 — Modules
  10. Terraform Part 10 — Loops and Conditionals
  11. Terraform Part 11 — Workspaces and Environment Separation
  12. Terraform Part 12 — Kubernetes and Helm Providers
  13. Terraform Part 13 — CI/CD Integration
  14. Terraform Part 14 — Testing and Policy
  15. Terraform Part 15 — Practical Patterns and Pitfalls
Table of contents

Table of contents

Why Modules

One VPC, three subnets, a few security groups, one RDS. Once you build this combination for the dev environment, you need the exact same thing for staging and the exact same thing for production. What’s wrong with just copy-pasting the same HCL code for each environment?

Modules solve these problems. The concept is like calling a well-crafted infrastructure pattern as a function. It’s identical to functions in programming. They take inputs, create resources through internal logic, and return outputs.

flowchart LR
    Input["Input variables\n(variables)"] --> Module["Module\n(Creates internal resources)"]
    Module --> Output["Output values\n(outputs)"]

    Input -.->|"cidr_block\nenvironment\ntags"| Module
    Module -.->|"vpc_id\nsubnet_ids\nsecurity_group_id"| Output

Basic Module Structure

A module is nothing special, really. Any directory containing .tf files is a module. By convention, they’re typically split into three files.

modules/vpc/
├── main.tf         # Actual resource definitions
├── variables.tf    # Input variables
└── outputs.tf      # Output values

Let’s create a simple VPC module.

# modules/vpc/variables.tf
variable "cidr_block" {
  description = "VPC CIDR block"
  type        = string
}

variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
}

variable "azs" {
  description = "List of availability zones to use"
  type        = list(string)
  default     = ["ap-northeast-2a", "ap-northeast-2c"]
}

variable "tags" {
  description = "Common tags"
  type        = map(string)
  default     = {}
}

Inputs are declared with variable blocks. Specifying the type, description, and default value prevents confusion when reusing later.

# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(
    var.tags,
    {
      Name        = "${var.environment}-vpc"
      Environment = var.environment
    }
  )
}

resource "aws_subnet" "public" {
  count = length(var.azs)

  vpc_id            = aws_vpc.this.id
  cidr_block        = cidrsubnet(var.cidr_block, 8, count.index)
  availability_zone = var.azs[count.index]

  tags = merge(
    var.tags,
    {
      Name = "${var.environment}-public-${count.index + 1}"
      Tier = "public"
    }
  )
}

There’s a convention of naming resources this or main. Externally, they’re referenced as module.vpc.aws_vpc.this, and since the module name itself provides context, the resource name is kept concise.

# modules/vpc/outputs.tf
output "vpc_id" {
  description = "Created VPC ID"
  value       = aws_vpc.this.id
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "cidr_block" {
  description = "VPC CIDR"
  value       = aws_vpc.this.cidr_block
}

Outputs selectively expose only the values that the outside world needs from what the module created. They are the module’s “public API.”

Calling a Module

Now let’s use this module.

# envs/dev/main.tf
module "vpc" {
  source = "../../modules/vpc"

  cidr_block  = "10.0.0.0/16"
  environment = "dev"
  azs         = ["ap-northeast-2a", "ap-northeast-2c"]

  tags = {
    Project = "my-service"
    Owner   = "platform-team"
  }
}

resource "aws_instance" "app" {
  ami           = "ami-0c9c942bd7bf113a2"
  instance_type = "t3.micro"
  subnet_id     = module.vpc.public_subnet_ids[0]  # Reference module output
}

Call it with a module block, specifying the module location with source. Access the module’s exposed output values using module.<name>.<output_name>.

When using a module for the first time or when source changes, you need to run terraform init again. Terraform needs to download or link the module.

source — Where to Get Modules From

Modules can be sourced from many places: local directories, Git repositories, the Terraform registry, S3, HTTP, etc. Each serves a different purpose.

flowchart TB
    Src["source specification"]

    Src --> Local["Local path\n'../../modules/vpc'"]
    Src --> Git["Git repo\n'git::https://...'"]
    Src --> Reg["Registry\n'terraform-aws-modules/vpc/aws'"]
    Src --> S3["S3/GCS\n's3::https://...'"]

    Local -.advantage.-> LocalPro["Fast iteration,\nsame repo"]
    Git -.advantage.-> GitPro["Version tags,\nteam/external sharing"]
    Reg -.advantage.-> RegPro["Official/verified modules,\nSemVer"]

Local path — When using modules within the same repo

module "vpc" {
  source = "../../modules/vpc"
}

The simplest approach. Suitable for teams developing or managing within a single repo.

Git repo — When sharing internal modules

module "vpc" {
  source = "git::https://github.com/my-company/terraform-modules.git//vpc?ref=v1.2.0"
}

// specifies a subdirectory within the repo. ref can be a tag, branch, or commit hash. It’s strongly recommended to pin versions using tags. Setting ref=main means your infrastructure can change without warning whenever the module is updated.

For SSH access:

module "vpc" {
  source = "git::ssh://git@github.com/my-company/terraform-modules.git//vpc?ref=v1.2.0"
}

Terraform Registry — Official or public modules

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"
  # ...
}

registry.terraform.io has verified public modules. The terraform-aws-modules family in particular are semi-official modules maintained by the AWS team and can be trusted. You can apply SemVer constraints with version.

Version NotationMeaning
"5.5.0"Exactly this version
"~> 5.5"Latest within 5.5.x (not 5.6.0)
"~> 5.5.0"Latest within 5.5.x (5.5.1 OK, 5.6.0 no)
">= 5.0, < 6.0"All of 5.x

Making Good Use of the Official Registry

No need to reinvent the wheel. The registry already has plenty of well-built modules.

Frequently used in AWS environments:

For example, creating a VPC with the official module takes about this much code.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.5"

  name = "prod-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["ap-northeast-2a", "ap-northeast-2c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true

  tags = {
    Environment = "prod"
  }
}

Written by hand, this would have exceeded 200 lines. With the module, you just pass the required inputs.

That said, blindly using official modules isn’t always the best approach. Official modules have many options for versatility, making them complex. If your requirements are simple, writing it yourself can be clearer. Also, when official modules change their internal structure, state migration may be required. Choose based on project scale and team maturity.

Calling Modules Within Modules

Modules can call other modules. Nesting is possible.

modules/
├── network/
│   ├── main.tf     # Uses module "vpc"
│   └── ...
└── vpc/
    └── ...
# modules/network/main.tf
module "vpc" {
  source = "../vpc"
  cidr_block  = var.cidr_block
  environment = var.environment
}

resource "aws_internet_gateway" "main" {
  vpc_id = module.vpc.vpc_id
}

Excessive nesting makes debugging difficult. About two levels deep is generally manageable.

Module Best Practices

These are principles refined through real-world usage.

1) Each module should represent a single logical unit

A VPC module should contain VPC, subnets, route tables, NAT, and other things that “make up a VPC.” Adding RDS to it blurs the module’s responsibility.

2) Always add types and descriptions to input variables

variable "instance_type" {
  description = "EC2 instance type (e.g., t3.micro, m5.large)"
  type        = string
  default     = "t3.micro"

  validation {
    condition     = can(regex("^(t3|t4g|m5|m6i)\\.", var.instance_type))
    error_message = "Allowed instance families: t3, t4g, m5, m6i"
  }
}

Using validation blocks to verify input values catches module users’ mistakes early.

3) Don’t expose internal resource names externally

Module users just need module.vpc.vpc_id to get the VPC ID. They don’t need to know how resources are composed internally. Only expose what’s necessary through outputs.

4) Include a README.md and examples

modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md       # Usage, input/output descriptions
└── examples/
    ├── basic/
    │   └── main.tf # Minimal usage example
    └── complete/
        └── main.tf # Example with all options

Documentation is essential when others will use the module. Tools like terraform-docs can auto-generate the README.

terraform-docs markdown table ./modules/vpc > ./modules/vpc/README.md

5) Practice proper version management

Apply SemVer to modules. Tagging with Git tags like v1.0.0, v1.1.0 lets users pin versions reliably.

6) Consider separate modules instead of deep conditional branching

If a single module ends up with var.environment == "prod" ? X : Y branches scattered everywhere, it might be better to split into separate modules. The more branches there are, the harder it is to read and test.

Creating a Module Registry

There are several ways to share modules within a team.

If the scale is small, the first approach (single terraform-modules repo) is a good starting point. As the company grows and the number of modules increases, evolving toward individual repos is natural.


In the next part, we’ll cover loops and conditionals. We’ll look at when to use count vs for_each, and how to cleanly organize complex configurations using dynamic blocks.

Part 10: Loops and Conditionals


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform Part 8 — State Management
Next Post
Terraform Part 10 — Loops and Conditionals