Terraform Modules Explained

Learn what Terraform modules are, when to use them, and how to construct them.

A Terraform module is a set of Terraform configuration files (.tf or .tf.tf.json) within a single directory. Even your simplest Terraform configuration, run from one directory, is a "root module."

Modules act as reusable building blocks. They encapsulate a collection of resources, data sources, variables, and outputs, treating them as a single logical unit. Think of them as programming functions: they take inputs, provision infrastructure, and produce outputs.

Why Use Terraform Modules?

Adopting Terraform modules offers significant benefits:

  • Organization and Readability: Break down large, complex infrastructure into smaller, manageable, logical units. This improves code readability and makes infrastructure definition easier to understand.
  • Reusability and Efficiency: Write infrastructure code once and reuse it across projects, teams, or environments. This eliminates redundant code, saves development time, and speeds up deployments.
  • Consistency and Standardization: Ensure common infrastructure components deploy uniformly across your organization. Modules enforce best practices, naming conventions, and security policies, reducing configuration drift and errors.
  • Abstraction of Complexity: Hide intricate resource provisioning details from higher-level configurations. Module users only need to know inputs and expected outputs, simplifying the experience and reducing cognitive load.
  • Version Control and Collaboration: Modules can be independently versioned and managed. Teams can collaborate on specific infrastructure components without affecting others. Module updates can be rolled out systematically.

When to Use Modules?

Deciding when to create a module involves identifying patterns and common infrastructure requirements. Use modules when you:

  • Repeat the same resources: If you copy/paste Terraform code for similar components (e.g., multiple VPCs, specific EC2 instances, or database clusters).
  • Need to enforce standards: To ensure infrastructure components always deploy with specific configurations, tags, or security settings across projects or teams.
  • Want to abstract complexity: When a complex set of resources forms a logical unit (e.g., an application stack including network, compute, and database).
  • Are building a shared library: For organizations providing pre-approved, tested infrastructure building blocks to development teams.

Keeping Your Code DRY with Modules

The Don't Repeat Yourself (DRY) principle is a cornerstone of good software engineering, and it applies powerfully to Infrastructure as Code. Terraform modules are your primary tool for achieving DRY code in your infrastructure definitions.

Without modules, provisioning similar infrastructure components (like multiple web servers, databases, or network segments) often leads to copying and pasting large blocks of Terraform code. This creates several problems:

  • Increased Maintenance Overhead: If you need to change a common configuration (e.g., update an instance type or add a new tag), you'd have to find and modify every copied instance of that code.
  • Higher Risk of Errors: Manual repetition increases the chance of inconsistencies, typos, or missed updates across different infrastructure deployments.
  • Reduced Readability: Large, repetitive configuration files become difficult to navigate and understand.

Modules solve this by allowing you to encapsulate a reusable piece of infrastructure. Instead of repeating the resource definitions, you simply call the module multiple times, each time providing potentially different input variables to customize its deployment.

Terraform Module Best Practices

To truly harness the power of Terraform modules, adhere to these best practices:

  • Modularize Thoughtfully: Break down infrastructure into logical layers (network, compute, database) or features. Each module should have a clear, single responsibility. Avoid creating overly complex or monolithic modules.
  • Encapsulation and Abstraction: Design modules to be as self-contained as possible. Only expose necessary inputs and outputs, hiding the internal implementation details. This makes modules easier to use and less prone to external breakage.
  • Avoid Hardcoding: Parameterize all configurable values using input variables. This makes your modules reusable across various environments and configurations without needing code changes within the module itself.
  • Semantic Versioning: Version your modules using semantic versioning (e.g., 1.0.0, 1.1.0, 2.0.0). This provides clear communication about changes, indicates backward compatibility, and helps manage upgrades for module consumers. Always pin module versions in your configurations.
  • Comprehensive Documentation: A well-documented module (with a detailed README.md) is a usable module. Explain its purpose, all inputs (with types, descriptions, and defaults), outputs, and provide clear usage examples.
  • Leverage the Terraform Registry (or your internal private registry): Before creating a new module, check if a suitable one already exists in the public Terraform Registry or your organization's private registry. Reusing existing, tested modules saves time and effort.
  • Consistent Naming Conventions: Adopt clear and consistent naming for variables, outputs, resources, and file names within your modules. Consistency improves readability and maintainability.
  • Automate Testing: Implement automated tests for your modules using tools like terraform validate, terraform fmt, terraform plan checks, and more advanced testing frameworks (e.g., Terratest). Testing ensures modules behave as expected and prevents regressions.
  • Minimize Dependencies: While modules can call other modules, try to minimize complex, multi-level module dependencies. Deep dependency trees can make debugging and understanding the overall infrastructure flow challenging.

Child and Root Modules

Module relationships are hierarchical:

  • Root Module: This module invokes another module. It provides input variable values to the child module and consumes its outputs. Your root Terraform configuration, the one you run terraform apply on, is always a root module.
  • Child Module: This module is called or instantiated by a root module. It defines resources and exposes functionality through inputs and outputs.

When a parent module calls a child module, the child module creates its own isolated scope for resources and variables. This isolation prevents naming collisions and ensures changes within a child module don't inadvertently affect resources in the parent or other child modules, promoting stable, predictable infrastructure.

Example of Calling a Child Module:

Imagine you have a child module named ec2-instance located in a directory ./modules/ec2-instance that creates an EC2 instance and takes instance_name and instance_type as inputs, and outputs the public_ip.

Your parent module (e.g., main.tf in your root directory) would call it like this:

Terraform

# In your root module's main.tf

# Define a variable for the instance type in the root module
variable "app_instance_type" {
  description = "The instance type for the application server"
  type        = string
  default     = "t2.medium"
}

# Call the 'ec2-instance' child module
module "app_server" {
  source  = "./modules/ec2-instance" # Path to your local child module
  
  # Pass values to the child module's input variables
  instance_name = "my-web-app-server"
  instance_type = var.app_instance_type # Using a variable from the parent module
  
  # You can also pass other arguments specific to the child module
  # key_name      = "my-ssh-key"
}

# Access an output from the child module
output "web_app_public_ip" {
  description = "Public IP address of the web application server"
  value       = module.app_server.instance_public_ip # Referencing the child module's output
}

In this example, module "app_server" is the block that calls the child module. The source argument specifies the path to the child module's directory, and the arguments inside the block (instance_name, instance_type) correspond to the input variables defined within the ec2-instance child module. The output block then demonstrates how to access values exposed by the child module.

+-------------------------------------------------+
|               ROOT MODULE                       |
|                                                 |
|  main.tf (calling configuration)                |
|                                                 |
|  +-------------------------------------------+  |
|  |           module "my_server" {          |  |
|  |             source = "./modules/ec2"    |  |
|  |  Input 1: instance_name = "web-server" <--- ---+
|  |  Input 2: instance_type = "t3.small"   <--- ---+
|  |           }                               |  |
|  |                                           |  |
|  +-------------------------------------------+  |
|  ^                                           |  |
|  | (Output from module)                      |  |
|  +-------------------------------------------+  |
|  | output "server_ip" {                      |  |
|  |   value = module.my_server.public_ip      |  |
|  | }                                         |  |
+-------------------------------------------------+
         |
         |  Calls / Instantiates
         v
+-------------------------------------------------+
|               CHILD MODULE                      |
|           (e.g., ./modules/ec2)                 |
|                                                 |
|  +---------------------+   +------------------+ |
|  |    VARIABLES (.tf)  |   |    OUTPUTS (.tf) | |
|  |---------------------|   |------------------| |
|  | variable "instance_name" <-------| output "public_ip" |
|  | variable "instance_type" <-------|                  |
|  +---------------------+   +------------------+ |
|             ^          |  Actual Infrastructure   |
|             |          |     Definitions          |
|             |          |                          |
|             |          |  +--------------------+  |
|             +----------|  | aws_instance       |  |
|                        |  |   - name           |  |
|                        |  |   - type           |  |
|                        |  |   - public_ip      |--+ Output source
|                        |  +--------------------+  |
|                        |                          |
+-------------------------------------------------+

Inputs and Outputs

Module reusability heavily relies on accepting inputs and producing outputs:

Outputs: Defined using output blocks within the module, these expose specific values about the created resources. These outputs can be used by the calling (parent) module, other modules, or external systems for further configuration or information.Terraform

# In outputs.tf within your module
output "instance_public_ip" {
  description = "The public IP address of the EC2 instance"
  value       = aws_instance.example.public_ip
}

Inputs (Variables): Defined using variable blocks within the module, these parameterize the module's behavior. Instead of hardcoding values like region, instance types, or CIDR blocks, define them as variables. When calling the module, you provide values for these variables, making the module flexible.Terraform

# In variables.tf within your module
variable "instance_type" {
  description = "The EC2 instance type"
  type        = string
  default     = "t3.micro"
}

How is a Module Constructed?

A Terraform module is a directory containing one or more .tf (Terraform configuration) or .tf.json files. While content varies, a well-structured module typically includes:

  • main.tf: The primary file containing core resource definitions and data sources.
  • variables.tf: Declares all expected input variables, ideally with descriptions and default values.
  • outputs.tf: Defines output values the module exposes to its calling module.
  • versions.tf: Specifies required Terraform versions and provider constraints, ensuring compatibility.
  • README.md: Crucial documentation. This file should explain the module's purpose, usage, inputs, outputs, and provide examples.
  • examples/ (optional directory): A common practice to include working examples of how to use the module, invaluable for users.

By embracing Terraform modules and these principles, you'll build robust, maintainable, and scalable IaC deployments, transforming infrastructure management into an efficient, automated process.

When and Why to Use Module Registries

Module registries are centralized repositories for sharing and discovering Terraform modules, essential for collaborative environments and large-scale IaC adoption.

When to use a module registry: Consider a registry when multiple teams need to share and reuse common infrastructure patterns, as they provide a single source of truth for approved modules. Registries simplify finding and integrating pre-built modules from official providers, the community, or internal teams. They are also vital for version management, allowing modules to be versioned (e.g., 1.0.0, 1.1.0). This enables users to pin specific versions, control updates, and prevent breaking changes for consistent deployments. Furthermore, many registries integrate with CI/CD pipelines, simplifying module testing, publishing, and consumption for automated workflows. Finally, for organizations requiring strict control over deployed infrastructure components, internal registries host validated and compliant modules, supporting compliance and governance efforts.

Why use a module registry: Registries offer simplified consumption; users can easily reference modules by name and version, eliminating the need to manage direct Git URLs or local paths. This leads to improved governance through centralized control over module versions and access, ensuring teams use approved and secure configurations. They also provide enhanced discovery and documentation, often with built-in documentation, examples, and search capabilities, making modules easier to understand and utilize. As your organization grows, a registry provides scalability, managing module needs and preventing unmanaged code sprawl. Public registries (like the Terraform Registry) offer high availability and act as a reliable source for widely used modules, while internal registries ensure your custom modules are always accessible, enhancing reliability.

You can utilize the public Terraform Registry for widely used modules, or establish a private registry (e.g., using Scalr, Terraform Cloud, Terraform, or Gitlab for your organization's specific and proprietary modules.

Tutorial - Create a S3 Module

Step 1: Create Your Module Directory and Files

Let's create a simple module that provisions an AWS S3 bucket.

First, create a directory for your module. We'll call it modules/s3_bucket.

my-terraform-project/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
    └── s3_bucket/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Step 2: Define the Module's Variables (modules/s3_bucket/variables.tf)

This file declares the inputs your module expects.

# modules/s3_bucket/variables.tf

variable "bucket_name" {
  description = "The name of the S3 bucket."
  type        = string
}

variable "acl" {
  description = "The ACL to apply to the bucket. (e.g., 'private', 'public-read')"
  type        = string
  default     = "private"
}

variable "tags" {
  description = "A map of tags to assign to the bucket."
  type        = map(string)
  default     = {}
}

Step 3: Define the Module's Resources (modules/s3_bucket/main.tf)

This file contains the actual resource definitions.

# modules/s3_bucket/main.tf

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
  acl    = var.acl

  tags = var.tags

  # Optional: Enable versioning
  versioning {
    enabled = true
  }

  # Optional: Enable server-side encryption
  server_side_encryption_configuration {
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256"
      }
    }
  }
}

Step 4: Define the Module's Outputs (modules/s3_bucket/outputs.tf)

This file declares values that the module will expose to the parent configuration.

# modules/s3_bucket/outputs.tf

output "bucket_id" {
  description = "The ID of the S3 bucket."
  value       = aws_s3_bucket.this.id
}

output "bucket_arn" {
  description = "The ARN of the S3 bucket."
  value       = aws_s3_bucket.this.arn
}

output "bucket_domain_name" {
  description = "The domain name of the S3 bucket."
  value       = aws_s3_bucket.this.bucket_domain_name
}

Step 5: Use Your Module in a Root Configuration

Now, in your main Terraform configuration (the "root" module), you can call and use your s3_bucket module.

First, configure your AWS provider in your root main.tf.

# my-terraform-project/main.tf

provider "aws" {
  region = "us-east-1" # Or your desired AWS region
}

# Call the s3_bucket module
module "my_first_bucket" {
  source = "./modules/s3_bucket" # Path to your local module directory

  bucket_name = "my-unique-application-bucket-12345" # Replace with a globally unique name
  acl         = "private"
  tags = {
    Environment = "Development"
    Project     = "TerraformModuleDemo"
  }
}

module "my_second_bucket" {
  source = "./modules/s3_bucket"

  bucket_name = "another-unique-app-bucket-67890" # Replace with a globally unique name
  acl         = "public-read" # Example of different ACL
  tags = {
    Environment = "Staging"
    Owner       = "TeamA"
  }
}

output "first_bucket_arn" {
  description = "ARN of the first S3 bucket."
  value       = module.my_first_bucket.bucket_arn
}

output "second_bucket_domain" {
  description = "Domain name of the second S3 bucket."
  value       = module.my_second_bucket.bucket_domain_name
}

Step 6: Initialize and Apply

Navigate to your my-terraform-project directory in your terminal and run the following commands:

Initialize Terraform: This downloads the necessary providers and processes the modules.

terraform init

Plan the changes: See what Terraform will do.

terraform plan

Apply the configuration: Create the resources.

terraform apply

Type yes when prompted to confirm.

After a successful terraform apply, you will see the output values for your buckets.

Here are 5 highly-regarded and frequently used modules, with examples demonstrating how they abstract complex infrastructure provisioning:

1. AWS VPC Module (terraform-aws-modules/vpc/aws)

This is arguably one of the most foundational and widely used Terraform modules. It simplifies the creation of a Virtual Private Cloud (VPC) with all its associated components like subnets (public, private, database), NAT gateways, internet gateways, route tables, and security groups. It dramatically reduces the boilerplate code for networking setup on AWS.

Example Usage:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0" # Always pin module versions!

  name = "my-application-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnets = ["10.0.10.0/24", "10.0.11.0/24", "10.0.12.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true

  tags = {
    Environment = "Dev"
    Project     = "MyApp"
  }
}

2. AWS EKS Module (terraform-aws-modules/eks/aws)

For deploying Amazon Elastic Kubernetes Service (EKS) clusters, this module is a go-to. It handles the provisioning of the EKS control plane, worker nodes (managed node groups or Fargate), and all necessary supporting resources like IAM roles, security groups, and networking components.

Example Usage:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "20.0.0"

  cluster_name    = "my-app-eks-cluster"
  cluster_version = "1.28"

  vpc_id                   = module.vpc.vpc_id
  subnet_ids               = module.vpc.private_subnets
  control_plane_subnet_ids = module.vpc.private_subnets # Often the same as subnet_ids

  enable_irsa = true

  eks_managed_node_groups = {
    default = {
      instance_types = ["t3.medium"]
      min_size       = 1
      max_size       = 3
      desired_size   = 2
    }
  }
}

3. Azure AKS Module (Azure/aks/azurerm)

Similar to the EKS module for AWS, this official Azure module simplifies the deployment of Azure Kubernetes Service (AKS) clusters. It provisions the AKS cluster, node pools, virtual network, subnets, and other necessary Azure resources to get a Kubernetes environment running.

Example Usage:

module "aks" {
  source  = "Azure/aks/azurerm"
  version = "8.1.0"

  resource_group_name = azurerm_resource_group.example.name
  location            = azurerm_resource_group.example.location

  cluster_name        = "my-app-aks-cluster"
  kubernetes_version  = "1.28.3"
  
  orchestrator_version = "1.28.3" # Redundant with kubernetes_version in newer versions, but good for clarity

  agents_pool_name = "default"
  agents_count     = 2
  agents_size      = "Standard_DS2_v2"

  network_plugin = "azure"
  vnet_subnet_id = azurerm_subnet.aks_subnet.id
}

4. Google Cloud VPC Network Module (GoogleCloudPlatform/vpc-network/google)

This official Google Cloud module provides a robust way to create and manage VPC networks, subnets, firewall rules, and VPC peering configurations on GCP. It's a fundamental building block for any GCP infrastructure.

Example Usage:

module "vpc_network" {
  source  = "GoogleCloudPlatform/vpc-network/google"
  version = "6.1.0"

  project_id   = "your-gcp-project-id"
  network_name = "my-app-vpc-network"

  subnets = [
    {
      subnet_name   = "app-subnet"
      subnet_ip     = "10.10.0.0/20"
      subnet_region = "us-central1"
    },
    {
      subnet_name   = "db-subnet"
      subnet_ip     = "10.10.16.0/20"
      subnet_region = "us-central1"
      description   = "Subnet for database instances"
    }
  ]

  # Example of creating firewall rules
  firewall_rules = [
    {
      name        = "allow-http-ingress"
      description = "Allow HTTP ingress"
      direction   = "INGRESS"
      ranges      = ["0.0.0.0/0"]
      allow = [{
        protocol = "tcp"
        ports    = ["80", "443"]
      }]
    }
  ]
}

5. AWS S3 Bucket Module (terraform-aws-modules/s3-bucket/aws)

While seemingly simple, configuring an S3 bucket with all its common features (versioning, logging, lifecycle rules, public access blocks, CORS, bucket policies, and notifications) can become verbose. This module streamlines S3 bucket creation, ensuring best practices for security and management.

Example Usage:

module "s3_bucket" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "3.10.0"

  bucket = "my-unique-app-data-bucket-12345"
  acl    = "private"

  control_object_ownership = true
  object_ownership         = "BucketOwnerPreferred"

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  versioning = {
    enabled = true
  }

  lifecycle_rule = [
    {
      id      = "log"
      enabled = true

      prefix = "log/"
      noncurrent_version_transition = [{
        days          = 30
        storage_class = "STANDARD_IA"
      }]
      noncurrent_version_expiration = {
        days = 90
      }
    }
  ]
}