In cloud computing, managing infrastructure efficiently has now become an important part of modern infrastructure operations. For instance, an IT professional who needs to spin up EC2 instances without knowing infrastructure as code has to manually go to the AWS management console. It might take several minutes to go through the interface, configure instance types, and set up security groups, etc. Now, imagine if there is a need to replicate the setup for multiple environments. There is a tendency to forget the configuration steps, leading to misconfiguration and inconsistencies in the infrastructure; this is where the power of automating with Terraform comes in.

Terraform is an infrastructure-as-code tool created by Hashicorp to write infrastructure configurations in declarative code. It helps for scalable and efficient deployments, and to manage your infrastructure programmatically. With Terraform, you can generate a consistent workflow to provision and manage all your resources in the infrastructure deployment lifecycle. Terraform can manage components such as storage, computing, networking, DNS entries, and the security of your applications.

Terraform has thousands of providers to manage several resources across different cloud platforms. You can find the providers in the Terraform Registry for platforms like Amazon Web Services (AWS), Azure, Google Cloud Platform, Helm, Kubernetes, etc.

A terraform workflow has three core stages;

The key components of a Terraform Configuration are as follows;

Why use Terraform Modules?

Before Terraform modules, cloud engineers typically wrote Terraform with monolithic configurations where all the resources were written in a single or few .tf or .tf.json files. As the need for complex infrastructure deployment grew, managing these setups became a difficult task due to code repetition. The need to create more modular, maintainable, and scalable infrastructure birthed the creation of Terraform modules.

Modules in Terraform allow you to organize all related resources into reusable packages by grouping them into specific .tf files. With Terraform modules, the problem of code repetition is addressed by adhering to the DRY (Don't Repeat Yourself) principle, allowing you to write code once and use it multiple times within your configuration. For example, instead of copying and pasting the same EC2 instance across multiple environments, you can define it as a module, and call it with specific variables in each environment.

A Terraform modules project should have the following;

Since Terraform modules make programmatic infrastructure management easier, they are perfect for large-scale and complex infrastructure deployment. For instance, a VPC module can be reused if you need to deploy a VPC across several environments (such as development, staging, and production). This helps to save time and ensure that your code is consistent across different environments.

This article will show how to deploy an EC2 instance on a default VPC using Terraform modules. We will use this to demonstrate the power of Terraform modules and how they streamline workflows easily. Whether you are a newbie to Terraform, or an expert looking to streamline your infrastructure operations, this article is worth reading.

Prerequisites Before getting started, ensure you have:

Now that we understand the steps and we have gotten the prerequisites, we can start creating our EC2 instance on AWS using Terraform.

Step-by-Step Terraform Module for EC2 Instance

  1. Define the folder structure:

    Create a directory for the Terraform project and create the and the folders to look like what we have below;

terraform-ec2
  modules/
    ├── ec2/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── security_group/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
main.tf       # Root configuration to call modules
variables.tf
outputs.tf
terraform.tfvar

The directory structure above is a well-organized Terraform modules project. The modules folder contains reusable child modules that represent each infrastructure component.

modules/ec2/:

modules/security_group/

Root Files

NB: file and folder names are only a standard for naming. You may give it any name.

  1. Provider configuration.

This allows Terraform to interact with Cloud Providers and other APIs. At the start of your infrastructure deployment, you must declare the providers your project requires so Terraform will install and use them.

The providers.tf file in your Terraform code is where you define the cloud provider to work with and the version.

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

  1. Create the Security Group Module.

    This configuration uses the Default VPC, there will not be any need to create a module for the VPC. We would create a module for the Security Group configuration.

Go to your security group module in ./modules/security_groups

modules/
  └── security_group/
      ├── main.tf
      ├── variables.tf
      └── outputs.tf

In the security modules/security_group/main.tf file, create the security group configurations. The code snippet below defines a configuration code that creates an AWS security group and defines its inbound (ingress) rules and outbound rules (egress).


resource "aws_security_group" "this" {
  name        = var.name
  description = var.description
  vpc_id      = var.vpc_id

  tags = merge(
    {
      Name = var.name
    },
    var.tags
  )
}

resource "aws_security_group_rule" "inbound_rule" {
  for_each = var.ingress_rules

  security_group_id = aws_security_group.this.id
  type              = "ingress"
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  protocol          = each.value.protocol
  cidr_blocks       = each.value.cidr_blocks
}

resource "aws_security_group_rule" "outbound_rule" {
  for_each = var.egress_rules

  security_group_id = aws_security_group.this.id
  type              = "egress"
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  protocol          = each.value.protocol
  cidr_blocks       = each.value.cidr_blocks
}

modules/security_group/variables.tf

variable "name" {
  description = "Name of the security group"
  type        = string
}

variable "description" {
  description = "Description of the security group"
  type        = string
  default     = "Managed by Terraform"
}

variable "vpc_id" {
  description = "The VPC ID where the security group will be created"
  type        = string
}

variable "ingress_rules" {
  description = "List of ingress rules"
  type = map(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = {}
}

variable "egress_rules" {
  description = "List of egress rules"
  type = map(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = {
    default = {
      from_port   = 0
      to_port     = 0
      protocol    = "-1" # All traffic
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

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

The code snippet defines Terraform variables for the configuration of AWS Security groups. These variables make the security groups reusable across different deployment employments.

modules/security_group/outputs.tf



output "security_group_id" {
  description = "ID of the security group"
  value       = aws_security_group.this.id
}

output "security_group_arn" {
  description = "ARN of the security group"
  value       = aws_security_group.this.arn
}

This code snippet above defines outputs for the module to expose information about the created security group.

  1. Create the EC2 modules.

The next step in the project is creating the module for the EC2 instance. Inside the ec2 folder under the modules, define the main.tf file.

The code snippet below provisions the EC2 instance and configures it to run the Apache Webserver using a user data script. Here, the instance configuration is dynamic with values provided in a separate variables.tf file. The this in aws_instance "this" is simply a resource name used within Terraform.

modules/ec2/main.tf

resource "aws_instance" "this" {
  ami           = var.ami
  instance_type = var.instance_type
  subnet_id     = var.subnet_id
  key_name      = var.key_name

  user_data = <<-EOF
              #!/bin/bash
              sudo apt update -y
              sudo apt install -y apache2
              sudo systemctl start apache2
             sudo  systemctl enable apache2
              EOF

  tags = merge(
    {
      Name = var.name
    },
    var.tags
  )

  security_groups = var.security_groups
}

The code snippet above creates the ec2 instance and sets up an Apache web server using the user data script. The variables referenced in the main.tf are also defined in the variables.tf file.

modules/ec2/variables.tf

variable "ami" {
  description = "AMI ID for the EC2 instance"
  type        = string
}

variable "instance_type" {
  description = "Instance type for the EC2 instance"
  type        = string
  default     = "t2.micro"
}

variable "subnet_id" {
  description = "Subnet ID where we will deploy the EC2 instance"
  type        = string
}

variable "key_name" {
  description = "Key pair name for accessing the EC2 instance"
  type        = string
}

variable "name" {
  description = "Name tag for the EC2 instance"
  type        = string
}

variable "security_groups" {
  description = "List of security groups to associate with the EC2 instance"
  type        = list(string)
  default     = []
}

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

Next, we would also create the outputs.tf file to expose key information about the created security group.

modules/ec2/outputs.tf

output "ec2_instance_id" {
  description = "ID of the EC2 instance"
  value       = aws_instance.this.id
}

output "instance_public_ip" {
  description = "Public IP address of the EC2 instance"
  value       = aws_instance.this.public_ip
}

output "instance_private_ip" {
  description = "Private IP address of the EC2 instance"
  value       = aws_instance.this.private_ip
}

  1. Creating the root module configuration

Now that all the modules are properly set up, we will define the root modules that will handle the creation of our infrastructure.

main.tf


# Fetch default VPC
data "aws_vpc" "default_vpc" {
  default = true
}

data "aws_subnets" "default_subnets" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

# Fetch the first subnet in the default VPC
data "aws_subnet" "default_subnet" {
  id = tolist(data.aws_subnets.default.ids)[0]
}



# Security Group Module
module "security_group" {
  source      = "./modules/security_group"
  name        = var.sg_name
  description = var.sg_description
  vpc_id      = data.aws_vpc.default_vpc.id

  ingress_rules = var.sg_ingress_rules

  egress_rules = var.sg_egress_rules

  tags = var.sg_tags
}


# EC2 Module
module "ec2_instance" {
  source          = "./modules/ec2"
  ami             = var.ami
  instance_type   = var.instance_type
  subnet_id       = data.aws_subnet.default_subnet.id
  key_name        = var.key_name
  name            = var.ec2_name
  security_groups = [module.security_group.security_group_id]

  tags = var.ec2_tags
}

The code snippet references the default VPC and subnet. It also calls out the modules in the modules folder and assigns values to them.

The root variables file. defines the input variables for the root module.

variables.tf

variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

# Security Group Variables
variable "sg_name" {
  description = "Name of the security group"
  type        = string
}

variable "sg_description" {
  description = "Description of the security group"
  type        = string
  default     = "Security group managed by Terraform"
}


variable "sg_ingress_rules" {
  description = "Ingress rules for the security group"
  type = map(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))
}

variable "sg_egress_rules" {
  description = "Egress rules for the security group"
  type = map(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = {
    default = {
      from_port   = 0
      to_port     = 0
      protocol    = "-1"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

variable "sg_tags" {
  description = "Tags for the security group"
  type        = map(string)
  default     = {}
}

# EC2 Instance Variables
variable "ami" {
  description = "AMI ID for the EC2 instance"
  type        = string
}

variable "instance_type" {
  description = "Instance type for the EC2 instance"
  type        = string
  default     = "t2.micro"
}


variable "key_name" {
  description = "Key pair name for accessing the EC2 instance"
  type        = string
}

variable "ec2_name" {
  description = "Name of the EC2 instance"
  type        = string
}

variable "ec2_tags" {
  description = "Tags for the EC2 instance"
  type        = map(string)
  default     = {}
}

outputs.tf

output "security_group_id" {
  description = "ID of the Security Group"
  value       = module.security_group.security_group_id
}

output "ec2_instance_id" {
  description = "ID of the EC2 instance"
  value       = module.ec2_instance.instance_id
}

output "ec2_public_ip" {
  description = "Public IP of the EC2 instance"
  value       = module.ec2_instance.instance_public_ip
}

Finally, we need to create a file that defines the default values for the variables in the variables.tf file

.

terraform.tfvar

aws_region      = "us-east-1"
ami             = "ami-12345678"                # Replace with Ubuntu 22.04 AMI ID
instance_type   = "t2.micro"
key_name        = "my-key-pair"                 # Replace with your Key Pair name
ec2_name        = "ubuntu-web-server"
ec2_tags = {
  Environment = "dev"
  Project     = "TerraformDemo"
}

sg_name        = "web-server-sg"
sg_description = "Security group for web server"
sg_ingress_rules = {
  ssh = {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  http = {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
sg_tags = {
  Environment = "dev"
  Project     = "TerraformDemo"
}

6. Initialize Terraform

Run the following command below to initialize Terraform and download the necessary provider plugins

terraform init

  1. Run Terraform Plan

    Before you apply, It is essential to preview the changes that Terraform will make

    terraform plan
    

  1. Terraform Apply

    Next, we will run the command below to apply the configuration to create the EC2 instance on AWS;

    terraform apply
    

Now, verify by checking the EC2 console to see the running instance and visit the public IP Address of the EC2 instance to view the Apache home screen.

  1. Lastly, to destroy the instance and associated resources, run:
terraform destroy

Conclusion

In this article, we explained how Terraform modules help our infrastructure code become scalable and reusable, especially in complex infrastructure setups. We also wrapped it up by creating an ec2 instance using Terraform modules on AWS. We now understand the power of terraform modules and their usefulness in deploying reusable and scalable modules.