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 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;
Before .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:
An Amazon Web Services Account.
AWS CLI configured with appropriate credentials. Follow the instructruction in the link to
Terraform installed (v1.0 or later). You can click to
terraform --version
Basic understanding of cloud computing; You can read about it here
Now that we understand the steps and we have gotten the prerequisites, we can start creating our EC2 instance on AWS using Terraform.
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/
:
main.tf
: Defines the resources required for the creation of the EC2 instance, such as instance type, AMI, and tags.variables.tf
: Specifies input variables to add parameters to the EC2 module (e.g., instance type or key pair).outputs.tf
: Exposes output values, such as instance IDs or public IPs, that can be accessed by the root module, or other modules.
modules/security_group/
main.tf
: It defines security group resources, such as ingress and egress rules for inbound and outbound connections.variables.tf
: Declares input variables to customize security group rules for your ec2 instance.outputs.tf
: Provides output values, such as security group IDs, for use in other modules or the root module.
Root Files
main.tf
: This is the file in the root directory that orchestrates the infrastructure by calling the child modules for deploymentvariables.tf
: Defines input variables for the root module to add parameters to the configurations, such as region or environment.outputs.tf
: Declares output values from the root module, often aggregating outputs from child modules (e.g., public IPs or security group IDs).
NB: file and folder names are only a standard for naming. You may give it any name.
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"
}
}
}
Terraform {}
block Specifies the provider required for the configuration. In our case, we are using the AWS provider.
Required_providers: Indicates the required plugins that Terraform needs to communicate with the Cloud Platform.
AWS: The name of the provider we are using
Source: The registry namespace where Terraform will locate the provider plugin. In our case, we will use the hashicorp/aws plugin maintained by Hashicorp. You can find other providers here
Version: Specifies the version of the provider plugin to use.
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
}
resource "aws_security_group" "this"
creates a security group resource in AWS. The identifier "this"
is an internal label within the Terraform configuration, and it is used to reference this specific resource elsewhere in the infrastructure code
name
: This is the name of the Security Group, passed as a variable var.name
.
description
: This is the description for the Security Group, also passed as a variable var.description
.
vpc_id
: It specifies the VPC where the Security Group will be created, defined by var.vpc_id
.
Ingress rules define the type of traffic that is allowed into the resource. In this case, it allows SSH from port 22
, and also allows HTTP (public web traffic) to access the web server.
Egress Rules specify the type of traffic allowed from the resource. (protocol = “-1”, CIDR 0.0.0.0/0) ensures that the instance can connect to the internet.
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.
name
specifies the name of the security group with a string type.description
provides the description of the security group.vpc_id
specifies the ID of the VPC where the security group will be created.
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.
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
}
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
Run Terraform Plan
Before you apply, It is essential to preview the changes that Terraform will make
terraform plan
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.
terraform destroy
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.