Using Terraform Modules for Reusable Infrastructure Components on AWS

Learn how to create and use Terraform modules to build reusable, maintainable, and scalable infrastructure components on AWS

Using Terraform Modules for Reusable Infrastructure Components on AWS

Terraform modules are containers for multiple resources that are used together. This guide shows you how to create and use modules to build reusable infrastructure components on AWS.

Video Tutorial

Learn more about using Terraform Modules in AWS in this comprehensive video tutorial:

Prerequisites

  • AWS CLI configured with appropriate permissions
  • Terraform installed (version 1.0.0 or later)
  • Basic understanding of Terraform and AWS
  • Git for version control

Project Structure

terraform-modules/
├── main.tf
├── variables.tf
├── outputs.tf
├── modules/
│   ├── vpc/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── ec2/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── rds/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── alb/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── terraform.tfvars

Creating Reusable Modules

VPC Module

Create modules/vpc/main.tf:

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

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

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_subnet" "public" {
  count             = var.az_count
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  map_public_ip_on_launch = true

  tags = merge(
    var.tags,
    {
      Name = "${var.project_name}-public-${count.index + 1}"
      Type = "Public"
    }
  )
}

resource "aws_subnet" "private" {
  count             = var.az_count
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + var.az_count)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = merge(
    var.tags,
    {
      Name = "${var.project_name}-private-${count.index + 1}"
      Type = "Private"
    }
  )
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = merge(
    var.tags,
    {
      Name = "${var.project_name}-igw"
    }
  )
}

resource "aws_eip" "nat" {
  count = var.az_count
  vpc   = true

  tags = merge(
    var.tags,
    {
      Name = "${var.project_name}-nat-eip-${count.index + 1}"
    }
  )
}

resource "aws_nat_gateway" "main" {
  count         = var.az_count
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = merge(
    var.tags,
    {
      Name = "${var.project_name}-nat-${count.index + 1}"
    }
  )

  depends_on = [aws_internet_gateway.main]
}

Create modules/vpc/variables.tf:

variable "project_name" {
  description = "Name of the project"
  type        = string
}

variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
}

variable "az_count" {
  description = "Number of AZs to use"
  type        = number
}

variable "tags" {
  description = "Tags to apply to resources"
  type        = map(string)
  default     = {}
}

Create modules/vpc/outputs.tf:

output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

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

output "private_subnet_ids" {
  description = "IDs of private subnets"
  value       = aws_subnet.private[*].id
}

output "nat_gateway_ids" {
  description = "IDs of NAT Gateways"
  value       = aws_nat_gateway.main[*].id
}

EC2 Module

Create modules/ec2/main.tf:

resource "aws_security_group" "main" {
  name        = "${var.project_name}-${var.name}-sg"
  description = "Security group for ${var.name}"
  vpc_id      = var.vpc_id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      description     = ingress.value.description
      from_port       = ingress.value.from_port
      to_port         = ingress.value.to_port
      protocol        = ingress.value.protocol
      cidr_blocks     = lookup(ingress.value, "cidr_blocks", null)
      security_groups = lookup(ingress.value, "security_groups", null)
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(
    var.tags,
    {
      Name = "${var.project_name}-${var.name}-sg"
    }
  )
}

resource "aws_launch_template" "main" {
  name_prefix   = "${var.project_name}-${var.name}-lt"
  image_id      = var.ami_id
  instance_type = var.instance_type

  network_interfaces {
    associate_public_ip_address = var.associate_public_ip
    security_groups            = [aws_security_group.main.id]
  }

  user_data = var.user_data != null ? base64encode(var.user_data) : null

  tag_specifications {
    resource_type = "instance"
    tags = merge(
      var.tags,
      {
        Name = "${var.project_name}-${var.name}"
      }
    )
  }
}

resource "aws_autoscaling_group" "main" {
  count               = var.create_asg ? 1 : 0
  name                = "${var.project_name}-${var.name}-asg"
  desired_capacity    = var.desired_capacity
  max_size           = var.max_size
  min_size           = var.min_size
  target_group_arns  = var.target_group_arns
  vpc_zone_identifier = var.subnet_ids

  launch_template {
    id      = aws_launch_template.main.id
    version = "$Latest"
  }

  dynamic "tag" {
    for_each = merge(
      var.tags,
      {
        Name = "${var.project_name}-${var.name}"
      }
    )
    content {
      key                 = tag.key
      value               = tag.value
      propagate_at_launch = true
    }
  }
}

Create modules/ec2/variables.tf:

variable "project_name" {
  description = "Name of the project"
  type        = string
}

variable "name" {
  description = "Name of the EC2 component"
  type        = string
}

variable "vpc_id" {
  description = "ID of the VPC"
  type        = string
}

variable "subnet_ids" {
  description = "IDs of subnets"
  type        = list(string)
}

variable "ami_id" {
  description = "ID of the AMI"
  type        = string
}

variable "instance_type" {
  description = "Instance type"
  type        = string
}

variable "associate_public_ip" {
  description = "Whether to associate public IP"
  type        = bool
  default     = false
}

variable "ingress_rules" {
  description = "Ingress rules for security group"
  type = list(object({
    description     = string
    from_port       = number
    to_port         = number
    protocol        = string
    cidr_blocks     = optional(list(string))
    security_groups = optional(list(string))
  }))
}

variable "user_data" {
  description = "User data script"
  type        = string
  default     = null
}

variable "create_asg" {
  description = "Whether to create an ASG"
  type        = bool
  default     = false
}

variable "desired_capacity" {
  description = "Desired capacity for ASG"
  type        = number
  default     = 1
}

variable "min_size" {
  description = "Minimum size for ASG"
  type        = number
  default     = 1
}

variable "max_size" {
  description = "Maximum size for ASG"
  type        = number
  default     = 1
}

variable "target_group_arns" {
  description = "ARNs of target groups"
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Tags to apply to resources"
  type        = map(string)
  default     = {}
}

Using the Modules

Create main.tf:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

locals {
  common_tags = {
    Environment = var.environment
    Project     = var.project_name
    Terraform   = "true"
  }
}

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

  project_name = var.project_name
  vpc_cidr     = var.vpc_cidr
  az_count     = var.az_count
  tags         = local.common_tags
}

module "web_servers" {
  source = "./modules/ec2"

  project_name        = var.project_name
  name               = "web"
  vpc_id             = module.vpc.vpc_id
  subnet_ids         = module.vpc.private_subnet_ids
  ami_id             = var.web_ami_id
  instance_type      = var.web_instance_type
  associate_public_ip = false
  create_asg         = true
  desired_capacity   = 2
  min_size           = 2
  max_size           = 4

  ingress_rules = [
    {
      description     = "HTTP from ALB"
      from_port       = 80
      to_port         = 80
      protocol        = "tcp"
      security_groups = [module.alb.security_group_id]
    }
  ]

  user_data = <<-EOF
              #!/bin/bash
              yum update -y
              yum install -y httpd
              systemctl start httpd
              systemctl enable httpd
              EOF

  tags = local.common_tags
}

Module Best Practices

  1. Module Structure

    • Keep modules focused and single-purpose
    • Use consistent file structure
    • Include README documentation
  2. Input Variables

    • Use descriptive variable names
    • Provide default values when appropriate
    • Use variable validation
  3. Outputs

    • Export necessary values
    • Use descriptive output names
    • Document output usage
  4. Versioning

    • Use semantic versioning
    • Tag releases
    • Document changes

Module Examples

Simple VPC Usage

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

  project_name = "my-app"
  vpc_cidr     = "10.0.0.0/16"
  az_count     = 2

  tags = {
    Environment = "prod"
    Team        = "infrastructure"
  }
}

Web Server with ASG

module "web_servers" {
  source = "./modules/ec2"

  project_name = "my-app"
  name         = "web"
  vpc_id       = module.vpc.vpc_id
  subnet_ids   = module.vpc.private_subnet_ids
  
  ami_id        = "ami-0735c191cf914754d"
  instance_type = "t3.micro"
  
  create_asg       = true
  desired_capacity = 2
  min_size         = 2
  max_size         = 4

  ingress_rules = [
    {
      description = "HTTP"
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]

  tags = {
    Environment = "prod"
    Service     = "web"
  }
}

Module Organization

  1. Local Modules

    • Store in modules/ directory
    • Use relative paths
    • Good for project-specific modules
  2. Remote Modules

    • Store in Git repositories
    • Use version tags
    • Good for shared modules
  3. Registry Modules

    • Publish to Terraform Registry
    • Follow registry guidelines
    • Good for public modules

Testing Modules

  1. Kitchen-Terraform
driver:
  name: terraform

provisioner:
  name: terraform

platforms:
  - name: aws

verifier:
  name: terraform
  systems:
    - name: default
      backend: local

suites:
  - name: default
  1. Terratest
package test

import (
    "testing"
    "github.com/gruntwork-io/terratest/modules/terraform"
)

func TestVpcModule(t *testing.T) {
    terraformOptions := &terraform.Options{
        TerraformDir: "../examples/vpc",
        Vars: map[string]interface{}{
            "project_name": "test",
            "vpc_cidr":    "10.0.0.0/16",
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)
}

Conclusion

You’ve learned how to create and use Terraform modules for AWS infrastructure. This approach provides:

  • Reusable infrastructure components
  • Consistent configurations
  • Maintainable code
  • Scalable architecture

Remember to:

  • Follow module best practices
  • Document your modules
  • Test thoroughly
  • Version control your modules