How to Create Custom EC2, Security Group, and VPC Modules in Terraform

Achintha Bandaranaike
15 min readFeb 26, 2023

--

Overview:

Infrastructure as Code (IaC) is becoming increasingly popular in the world of cloud computing, allowing developers and DevOps teams to manage infrastructure in a more automated and scalable way. Terraform is one of the most popular IaC tools available today, providing a simple and powerful way to define, deploy, and manage infrastructure resources.

In this article, I’ll demonstrate how to create a modular and repeatable infrastructure using Terraform. We’ll cover the creation of a custom EC2 module, a security group module, and a VPC module, and show how to connect them together to create a web server in the cloud. By the end of this article, you’ll have a reusable Terraform module that you can use to quickly and easily provision these resources for your own projects.

What Is Terraform Module?

In Terraform, a module is a self-contained package of Terraform configurations, which can be used to create and manage a specific set of infrastructure resources. Modules are used to encapsulate a set of resources and their configuration into a single entity that can be reused across different Terraform configurations.

A Terraform module consists of a collection of .tf files, including a main.tf file that defines the resources to be created, a variables.tf file that defines input variables for the module, an outputs.tf file that defines output values that the module provides, and other optional files such as a README.md file to document the module.

Modules are useful in Terraform because they allow you to organize and reuse your infrastructure code, making it easier to manage and maintain your infrastructure. By creating a module for a specific set of resources, you can abstract away the complexity of creating and managing those resources, and create reusable building blocks that can be easily shared and reused across different projects and teams.

In addition, Terraform modules can be published to the Terraform Registry, a public repository of Terraform modules that can be easily shared and used by the Terraform community. By publishing your modules to the registry, you can make it easy for others to use and benefit from your infrastructure code.

Why do we use Terraform Modules?

There are several reasons why we use Terraform modules:

  1. Reusability: Modules allow you to create reusable building blocks of infrastructure code. By creating a module for a specific set of resources, you can abstract away the complexity of creating and managing those resources, and create reusable components that can be easily shared and reused across different projects and teams.
  2. Consistency: Modules ensure consistency in your infrastructure by enforcing the same configurations and settings across all instances of the module. This helps to avoid human error and ensures that all resources created by the module are consistent.
  3. Scalability: Modules allow you to scale your infrastructure easily by simplifying the process of creating and managing resources. As your infrastructure needs grow, you can add additional instances of the module to your Terraform configurations, and easily manage them with the same set of configurations.
  4. Maintainability: Modules make your infrastructure code more maintainable by reducing the amount of code you need to write and maintain. By abstracting away the complexity of managing resources, you can focus on the high-level architecture of your infrastructure, making it easier to manage and maintain over time.
  5. Collaboration: Modules make it easy to collaborate on infrastructure code by creating reusable components that can be easily shared and used by different teams and projects. This helps to reduce duplication of effort and ensures that everyone is using the same configurations and settings.

Overall, Terraform modules help to improve the reusability, consistency, scalability, maintainability, and collaboration of your infrastructure code, making it easier to manage and maintain over time.

Steps Guide:

  1. Create a custom EC2 module that can be used as repeatable infrastructure.
  2. Create a Security Group module that allows for HTTP web access.
  3. Create a VPC module.

Step 1: Create EC2 Module

1st you need to create a variable.tf file for your EC2 module, which will define the input variables required to create an EC2 instance. I create my EC2 module folder name: ec2-resources

Define the EC2 instance attributes that you need to create (e.g., instance type, AMI, key pair, etc.).

Create an EC2 module in your Terraform configuration file, specifying the necessary inputs and outputs.

Write the code for the EC2 module, including the necessary resources (e.g., instance, security group, etc.).

Terraform file name: variable.tf

variable "ami_id" {
default = "ami-0f2eac25772cd4e36"
}

variable "instance_type" {
default = "t2.micro"
}
variable "vpc_id" {
type = string
}
variable "security_group_id" {
type = string
}
variable "public_subnet_id" {
type = string
}

Here, we’re defining variables for the instance type, AMI, and key pair, and passing them to the ec2-resources module, which is defined in a separate file.

Next, you can create the main terraform file for the EC2 module. This is the child main file for the EC2 module.

resource "aws_instance" "webserver-ec2" {
ami = var.ami_id
instance_type = var.instance_type
vpc_security_group_ids = [var.security_group_id]
subnet_id = var.public_subnet_id
tags = {
Name = "web_server-ec2"
}
user_data = <<-EOF
#!/bin/bash
sudo yum update -y
sudo amazon-linux-extras install nginx1 -y
sudo systemctl enable nginx
sudo systemctl start nginx
EOF
}

Final view of the ec2 module:

Step 2: Create Security Group Module

The security group module will consist of a main.tf file, a outputs.tf file, a variable.tf file. I create my EC2 module folder name: security-resources

Determine the security group rules that you need to create (e.g., allow SSH access from a specific IP range).

Create a security group module in your Terraform configuration file, specifying the necessary inputs and outputs.

Write the code for the security group module, including the necessary resources (e.g., security group, security group rule, etc.).

variable.tf

variable "vpc_id" {
type = string
}

outputs.tf

output "security_group_id" {
value = aws_security_group.http_access.id
}

main.tf

In this example, we’re defining variables for the security group name, description, VPC ID, and a list of security group rules. We’re passing these variables to the security-resources module, which is defined in a separate file.

resource "aws_security_group" "http_access" {
name = "http_access"
description = "SG module Achintha Bandaranaike"
vpc_id = var.vpc_id

ingress {
from_port = "22"
to_port = "22"
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}

ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}

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

Summary view of the Security Group Module

Step 4: Create VPC Module

The security group module will consist of a main.tf file, a outputs.tf file, a variable.tf file. I create my EC2 module folder name: network-resources

Define the VPC attributes that you need to create (e.g., CIDR block, availability zones, subnets, etc.).

Create a VPC module in your Terraform configuration file, specifying the necessary inputs and outputs.

Write the code for the VPC module, including the necessary resources (e.g., VPC, subnets, route tables, etc.).

variable.tf

variable "vpc_id" {
type = string
}

outputs.tf

output "vpc_id" {
value = aws_vpc.default.id
}
output "public_subnet_id" {
value = aws_subnet.public.id
}

main.tf

resource "aws_vpc" "default" {
cidr_block = "10.0.0.0/16"

tags = {
Name = "default_vpc"
}
}

resource "aws_subnet" "public" {
vpc_id = aws_vpc.default.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
tags = {
Name = "public_subnet"
}
}

Summary view of the VPC module

Step 5: Create a Root Module

Now we can have created the child modules, we need to connect them in our main.tf file on the root module.

We need to make sure our EC2 is connected to the vpc and security group we have formed.

provider.tf

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

provider "aws" {
profile = "default"
region = "ap-southeast-1"

}

main.tf

module "network-resources" {
source = "./network-resources"
vpc_id = module.network-resources.vpc_id
}

module "security-resources" {
source = "./security-resources"
vpc_id = module.network-resources.vpc_id
}

module "ec2-resources" {
source = "./ec2-resources"

vpc_id = module.network-resources.vpc_id
security_group_id = module.security-resources.security_group_id
public_subnet_id = module.network-resources.public_subnet_id

}

Summary of final view:

Note: Please ignore terraform.tfstate , .terraform.lock.hcl and .terraform files. After run the terraform plan command you can see this terraform files in your directory.

Step 6: Run

Make sure you have already inserted your AWS credentials and are operating from the root directory before starting these Terraform commands.

terraform init :

The terraform init the command is used to initialize a new or existing Terraform configuration. This command downloads the required provider plugins and sets up the backend for storing state.

terraform init

terraform plan:

The terraform plan the command is used to create an execution plan for the Terraform configuration. This command shows what resources Terraform will create, modify, or delete when applied.

Terraform used the selected providers to generate the following execution plan. Resource actions are indicat
following symbols:
+ create

Terraform will perform the following actions:

# module.ec2-resources.aws_instance.webserver-ec2 will be created
+ resource "aws_instance" "webserver-ec2" {
+ ami = "ami-0f2eac25772cd4e36"
+ arn = (known after apply)
+ associate_public_ip_address = (known after apply)
+ availability_zone = (known after apply)
+ cpu_core_count = (known after apply)
+ cpu_threads_per_core = (known after apply)
+ disable_api_stop = (known after apply)
+ disable_api_termination = (known after apply)
+ ebs_optimized = (known after apply)
+ get_password_data = false
+ host_id = (known after apply)
+ host_resource_group_arn = (known after apply)
+ iam_instance_profile = (known after apply)
+ id = (known after apply)
+ instance_initiated_shutdown_behavior = (known after apply)
+ instance_state = (known after apply)
+ instance_type = "t2.micro"
+ ipv6_address_count = (known after apply)
+ ipv6_addresses = (known after apply)
+ key_name = (known after apply)
+ monitoring = (known after apply)
+ outpost_arn = (known after apply)
+ password_data = (known after apply)
+ placement_group = (known after apply)
+ placement_partition_number = (known after apply)
+ primary_network_interface_id = (known after apply)
+ private_dns = (known after apply)
+ private_ip = (known after apply)
+ public_dns = (known after apply)
+ public_ip = (known after apply)
+ secondary_private_ips = (known after apply)
+ security_groups = (known after apply)
+ source_dest_check = true
+ subnet_id = (known after apply)
+ tags = {
+ "Name" = "web_server-ec2"
}
+ tags_all = {
+ "Name" = "web_server-ec2"
}
+ tenancy = (known after apply)
+ user_data = "9d990e2d1ca50f9b8b756837cc09291041f92f32"
+ user_data_base64 = (known after apply)
+ user_data_replace_on_change = false
+ vpc_security_group_ids = (known after apply)

+ capacity_reservation_specification {
+ capacity_reservation_preference = (known after apply)

+ capacity_reservation_target {
+ capacity_reservation_id = (known after apply)
+ capacity_reservation_resource_group_arn = (known after apply)
}
}

+ ebs_block_device {
+ delete_on_termination = (known after apply)
+ device_name = (known after apply)
+ encrypted = (known after apply)
+ iops = (known after apply)
+ kms_key_id = (known after apply)
+ snapshot_id = (known after apply)
+ tags = (known after apply)
+ throughput = (known after apply)
+ volume_id = (known after apply)
+ volume_size = (known after apply)
+ volume_type = (known after apply)
}

+ enclave_options {
+ enabled = (known after apply)
}

+ ephemeral_block_device {
+ device_name = (known after apply)
+ no_device = (known after apply)
+ virtual_name = (known after apply)
}

+ maintenance_options {
+ auto_recovery = (known after apply)
}

+ metadata_options {
+ http_endpoint = (known after apply)
+ http_put_response_hop_limit = (known after apply)
+ http_tokens = (known after apply)
+ instance_metadata_tags = (known after apply)
}

+ network_interface {
+ delete_on_termination = (known after apply)
+ device_index = (known after apply)
+ network_card_index = (known after apply)
+ network_interface_id = (known after apply)
}

+ private_dns_name_options {
+ enable_resource_name_dns_a_record = (known after apply)
+ enable_resource_name_dns_aaaa_record = (known after apply)
+ hostname_type = (known after apply)
}

+ root_block_device {
+ delete_on_termination = (known after apply)
+ device_name = (known after apply)
+ encrypted = (known after apply)
+ iops = (known after apply)
+ kms_key_id = (known after apply)
+ tags = (known after apply)
+ throughput = (known after apply)
+ volume_id = (known after apply)
+ volume_size = (known after apply)
+ volume_type = (known after apply)
}
}

# module.network-resources.aws_subnet.public will be created
+ resource "aws_subnet" "public" {
+ arn = (known after apply)
+ assign_ipv6_address_on_creation = false
+ availability_zone = (known after apply)
+ availability_zone_id = (known after apply)
+ cidr_block = "10.0.1.0/24"
+ enable_dns64 = false
+ enable_resource_name_dns_a_record_on_launch = false
+ enable_resource_name_dns_aaaa_record_on_launch = false
+ id = (known after apply)
+ ipv6_cidr_block_association_id = (known after apply)
+ ipv6_native = false
+ map_public_ip_on_launch = true
+ owner_id = (known after apply)
+ private_dns_hostname_type_on_launch = (known after apply)
+ tags = {
+ "Name" = "public_subnet"
}
+ tags_all = {
+ "Name" = "public_subnet"
}
+ vpc_id = (known after apply)
}

# module.network-resources.aws_vpc.default will be created
+ resource "aws_vpc" "default" {
+ arn = (known after apply)
+ cidr_block = "10.0.0.0/16"
+ default_network_acl_id = (known after apply)
+ default_route_table_id = (known after apply)
+ default_security_group_id = (known after apply)
+ dhcp_options_id = (known after apply)
+ enable_classiclink = (known after apply)
+ enable_classiclink_dns_support = (known after apply)
+ enable_dns_hostnames = (known after apply)
+ enable_dns_support = true
+ enable_network_address_usage_metrics = (known after apply)
+ id = (known after apply)
+ instance_tenancy = "default"
+ ipv6_association_id = (known after apply)
+ ipv6_cidr_block = (known after apply)
+ ipv6_cidr_block_network_border_group = (known after apply)
+ main_route_table_id = (known after apply)
+ owner_id = (known after apply)
+ tags = {
+ "Name" = "default_vpc"
}
+ tags_all = {
+ "Name" = "default_vpc"
}
}

# module.security-resources.aws_security_group.http_access will be created
+ resource "aws_security_group" "http_access" {
+ arn = (known after apply)
+ description = "Allow HTTP access"
+ egress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 0
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "-1"
+ security_groups = []
+ self = false
+ to_port = 0
},
]
+ id = (known after apply)
+ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 22
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 22
},
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 80
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 80
},
+ name = "http_access"
+ name_prefix = (known after apply)
+ owner_id = (known after apply)
+ revoke_rules_on_delete = false
+ tags_all = (known after apply)
+ vpc_id = (known after apply)
}

Plan: 4 to add, 0 to change, 0 to destroy.

terraform apply:

The terraform apply the command is used to apply the Terraform configuration and create or modify resources in the target environment.

Terraform used the selected providers to generate the following execution plan. Resource actions are indicat
following symbols:
+ create

Terraform will perform the following actions:

# module.ec2-resources.aws_instance.webserver-ec2 will be created
+ resource "aws_instance" "webserver-ec2" {
+ ami = "ami-0f2eac25772cd4e36"
+ arn = (known after apply)
+ associate_public_ip_address = (known after apply)
+ availability_zone = (known after apply)
+ cpu_core_count = (known after apply)
+ cpu_threads_per_core = (known after apply)
+ disable_api_stop = (known after apply)
+ disable_api_termination = (known after apply)
+ ebs_optimized = (known after apply)
+ get_password_data = false
+ host_id = (known after apply)
+ host_resource_group_arn = (known after apply)
+ iam_instance_profile = (known after apply)
+ id = (known after apply)
+ instance_initiated_shutdown_behavior = (known after apply)
+ instance_state = (known after apply)
+ instance_type = "t2.micro"
+ ipv6_address_count = (known after apply)
+ ipv6_addresses = (known after apply)
+ key_name = (known after apply)
+ monitoring = (known after apply)
+ outpost_arn = (known after apply)
+ password_data = (known after apply)
+ placement_group = (known after apply)
+ placement_partition_number = (known after apply)
+ primary_network_interface_id = (known after apply)
+ private_dns = (known after apply)
+ private_ip = (known after apply)
+ public_dns = (known after apply)
+ public_ip = (known after apply)
+ secondary_private_ips = (known after apply)
+ security_groups = (known after apply)
+ source_dest_check = true
+ subnet_id = (known after apply)
+ tags = {
+ "Name" = "web_server-ec2"
}
+ tags_all = {
+ "Name" = "web_server-ec2"
}
+ tenancy = (known after apply)
+ user_data = "9d990e2d1ca50f9b8b756837cc09291041f92f32"
+ user_data_base64 = (known after apply)
+ user_data_replace_on_change = false
+ vpc_security_group_ids = (known after apply)

+ capacity_reservation_specification {
+ capacity_reservation_preference = (known after apply)

+ capacity_reservation_target {
+ capacity_reservation_id = (known after apply)
+ capacity_reservation_resource_group_arn = (known after apply)
}
}

+ ebs_block_device {
+ delete_on_termination = (known after apply)
+ device_name = (known after apply)
+ encrypted = (known after apply)
+ iops = (known after apply)
+ kms_key_id = (known after apply)
+ snapshot_id = (known after apply)
+ tags = (known after apply)
+ throughput = (known after apply)
+ volume_id = (known after apply)
+ volume_size = (known after apply)
+ volume_type = (known after apply)
}

+ enclave_options {
+ enabled = (known after apply)
}

+ ephemeral_block_device {
+ device_name = (known after apply)
+ no_device = (known after apply)
+ virtual_name = (known after apply)
}

+ maintenance_options {
+ auto_recovery = (known after apply)
}

+ metadata_options {
+ http_endpoint = (known after apply)
+ http_put_response_hop_limit = (known after apply)
+ http_tokens = (known after apply)
+ instance_metadata_tags = (known after apply)
}

+ network_interface {
+ delete_on_termination = (known after apply)
+ device_index = (known after apply)
+ network_card_index = (known after apply)
+ network_interface_id = (known after apply)
}

+ private_dns_name_options {
+ enable_resource_name_dns_a_record = (known after apply)
+ enable_resource_name_dns_aaaa_record = (known after apply)
+ hostname_type = (known after apply)
}

+ root_block_device {
+ delete_on_termination = (known after apply)
+ device_name = (known after apply)
+ encrypted = (known after apply)
+ iops = (known after apply)
+ kms_key_id = (known after apply)
+ tags = (known after apply)
+ throughput = (known after apply)
+ volume_id = (known after apply)
+ volume_size = (known after apply)
+ volume_type = (known after apply)
}
}

# module.network-resources.aws_subnet.public will be created
+ resource "aws_subnet" "public" {
+ arn = (known after apply)
+ assign_ipv6_address_on_creation = false
+ availability_zone = (known after apply)
+ availability_zone_id = (known after apply)
+ cidr_block = "10.0.1.0/24"
+ enable_dns64 = false
+ enable_resource_name_dns_a_record_on_launch = false
+ enable_resource_name_dns_aaaa_record_on_launch = false
+ id = (known after apply)
+ ipv6_cidr_block_association_id = (known after apply)
+ ipv6_native = false
+ map_public_ip_on_launch = true
+ owner_id = (known after apply)
+ private_dns_hostname_type_on_launch = (known after apply)
+ tags = {
+ "Name" = "public_subnet"
}
+ tags_all = {
+ "Name" = "public_subnet"
}
+ vpc_id = (known after apply)
}

# module.network-resources.aws_vpc.default will be created
+ resource "aws_vpc" "default" {
+ arn = (known after apply)
+ cidr_block = "10.0.0.0/16"
+ default_network_acl_id = (known after apply)
+ default_route_table_id = (known after apply)
+ default_security_group_id = (known after apply)
+ dhcp_options_id = (known after apply)
+ enable_classiclink = (known after apply)
+ enable_classiclink_dns_support = (known after apply)
+ enable_dns_hostnames = (known after apply)
+ enable_dns_support = true
+ enable_network_address_usage_metrics = (known after apply)
+ id = (known after apply)
+ instance_tenancy = "default"
+ ipv6_association_id = (known after apply)
+ ipv6_cidr_block = (known after apply)
+ ipv6_cidr_block_network_border_group = (known after apply)
+ main_route_table_id = (known after apply)
+ owner_id = (known after apply)
+ tags = {
+ "Name" = "default_vpc"
}
+ tags_all = {
+ "Name" = "default_vpc"
}
}

# module.security-resources.aws_security_group.http_access will be created
+ resource "aws_security_group" "http_access" {
+ arn = (known after apply)
+ description = "Allow HTTP access"
+ egress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 0
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "-1"
+ security_groups = []
+ self = false
+ to_port = 0
},
]
+ id = (known after apply)
+ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 22
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 22
},
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 80
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 80
},
]
+ name = "http_access"
+ name_prefix = (known after apply)
+ owner_id = (known after apply)
+ revoke_rules_on_delete = false
+ tags_all = (known after apply)
+ vpc_id = (known after apply)
}

Plan: 4 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.

Enter a value: yes

module.network-resources.aws_vpc.default: Creation complete after 2s [id=vpc-0e16cf1ed443982f6]
module.network-resources.aws_subnet.public: Creating...
module.security-resources.aws_security_group.http_access: Creating...
module.security-resources.aws_security_group.http_access: Creation complete after 2s [id=sg-07597848ac26a5e7a]
module.network-resources.aws_subnet.public: Still creating... [10s elapsed]
module.network-resources.aws_subnet.public: Creation complete after 10s [id=subnet-0f36ada5c5389176f]
module.ec2-resources.aws_instance.webserver-ec2: Creating...
module.ec2-resources.aws_instance.webserver-ec2: Still creating... [11s elapsed]
module.ec2-resources.aws_instance.webserver-ec2: Still creating... [21s elapsed]

Final output:

Step 7: Go to the AWS console and verify

  1. EC2

2. Security Group

3. VPC

Thanks for reading! Let’s see you in the next article. Don’t forget to follow me via medium and leave a 👏.

--

--

Achintha Bandaranaike
Achintha Bandaranaike

Written by Achintha Bandaranaike

AWS Community Builder ☁️| Cloud Enthusiast | 3xAWS | 3xAzure | Terraform Certified | 1xGCP

No responses yet