After recently passing the Terraform Associate exam, I wanted to challenge myself and apply my newly acquired Terraform knowledge to build a project that would deploy a website or application entirely with just a click of a button or, in this case, by typing ‘terraform apply’.
Deploying WordPress on a two-tier AWS architecture with Terraform offers a robust and scalable solution to host your wordpress website. AWS provides various services for creating a resilient infrastructure, and Terraform automates the deployment process.
The diagram below shows the overview of what we’ll be setting up in this blog.
Table of Contents
Prerequistes
- Basic knowledge of Terraform & AWS
- Installed AWS CLI
- Installed Terraform CLI
The content provided here is not a step-by-step guide to building and deploying a two-tier WordPress site. Rather, it aims to offer a broad perspective on the overall process of how the code in my GitHub is implemented, The Terraform code is structured in a modular fashion, with each resource defined within its own main.tf
file. This modular approach facilitates better organization and management of the code, allowing individual components to be maintained and scaled independently. It does however make it damn tricky to write a blog post about..
Link to code in Github
What is a 2-Tier Architecture?
A 2-tier architecture is a model of IT infrastructure that typically separates application components into two distinct layers: the presentation layer, which handles the user interface and user interaction, and the data layer, which consists of database servers where data is stored and managed.
The Presentation Layer: Also known as the front-end, it is responsible for the user interface and interaction with end-users. In the context of WordPress, this layer encompasses the web server that processes HTTP requests and serves web pages to visitors.
The Data Layer: Also referred to as the back-end, it handles data storage and access. For a WordPress site, this layer includes the management of the database where posts, comments, user information, and other content are securely stored.
Benefits of a 2-Tier Architecture:
- Clear Separation: This architecture clearly separates responsibilities between the presentation and data layers, which simplifies the management and maintenance of the application.
- Flexibility and Scalability: It allows for independent scaling of the presentation layer, without affecting the data layer, optimizing performance and ensuring a seamless user experience, particularly during times of increased traffic.
Step 1: Setting up the folder structure and defining providers
Within your working directory, create the files main.tf
, output.tf
, variables.tf
, and a folder named modules
. These are what’s referred to as your root modules, named as such because they are located in the root of your working directory. In main.tf
, you should define the versions of the AWS provider and Terraform provider that you wish to use. If you opt to leave these version specifications blank, Terraform will automatically select the latest versions available.
Provider “aws”: Specifies the AWS provider for Terraform. It sets the region to “ap-southeast-2”, indicating that the resources will be provisioned in the Sydney, Australia data center. The role of this resource is to authenticate Terraform with the specified AWS region and allow it to manage AWS resources.
Terraform block: Defines the required providers for the Terraform configuration. In this case, it specifies that the “aws” provider is required with a specific version of 5.0. This block ensures that the correct version of the AWS provider is used for the configuration.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Configure the AWS Provider
provider "aws" {
region = "ap-southeast-2"
}
Modules: Setting up child modules.
It’s best practice to keep your Terraform code modular. There are a number of reasons for this, but the main one I’ve found is that it’s much easier to read each part of the code when it’s broken up. When I first started this project, I put all of my code into the main.tf / variables.tf
files which quickly became quite difficult to track and troubleshoot.
Within your ‘modules’ folder, create a new folder for each resource you will be creating. Then, inside that folder, create three files named main.tf
, outputs.tf
, and variables.tf
. The screenshot illustrates this structure using an EC2 instance as an example.
Each child module will only contain the code required for that resource.
Step 2: Let's Build Some Resources.
VPC
I’ll now walk through the creation of some of the resource and describe how they link together. Lets create a VPC (Virtual Private Cloud) to host our resources. It’s pretty straight forward, all we need to do is to give it the cidr block that our VPC will have and to give the VPC a name, both which are defined within the variable.tf file.
Main.tf
resource "aws_vpc" "vpc" {
cidr_block = var.cidr_block
tags = {
Name = var.vpc_name
}
}
variable.tf
variable "cidr_block" {
description = "The CIDR block for the VPC"
type = string
default = "10.10.0.0/16"
}
variable "vpc_name" {
description = "The name tag of the VPC"
type = string
default = "ProductionVPC"
}
When working with child modules, you’ll regularly need to pass attributes from one module to another; this is where output.tf
comes into play. Below, we’re grabbing the ID attribute of the VPC and passing it out of our child module to the root main.tf
output "vpc_id" {
value = aws_vpc.vpc.id
}
So now that we’ve configured our VPC, we need to define it in our root main.tf
so that Terraform knows to build the VPC. All you need to do now is simply invoke the module.
You may notice that there are repeated arguments for the VPC defined within the child variables.tf
and the root main.tf
. In Terraform, the default value in a variable provides a fallback option. If you don’t set the variable, Terraform uses the default. Here, you can see I’ve set vpc_name
to “WordPressVPC” and cidr_block to “10.0.0.16” which will override the defaults that have been set.
# Create a VPC
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
vpc_name = "WordpressVPC"
}
Availability Zones
Lets grab the availability zones for this AWS Region. This data block is simply query the AWS API for what availability zones have a state set to available within the region and then it’s being passed out of the module via the output file.
data "aws_availability_zones" "available" {
state = "available"
}
output "availability_zone_names" {
value = data.aws_availability_zones.available.names
}
Public Subnets
Below, we’re creating two public subnets within our VPC, each in a different AZs (Availability Zones). public_subnet_1
will be created within the first available AZ in the region, and public_subnet_2
will be created in the second. This is due to var.availability_zone_names[0]
and var.availability_zone_names[1]
. The square brackets at the end of the variable indicate the selection of the first and second items within the list, respectively, with the list of AZs being passed in from the availability_zones
. module we created earlier.
The cidrsubnet
function in Terraform is used to calculate a specific subnet’s CIDR block within a larger network. Here we’re giving it a the CIDR block we gave the VPC (10.0.0.0/16), the function extends the mask by a specified number of bits (8 bits in this case) to create a smaller subnet. We’re also assigns a unique numerical identifier to each subnet. For example, with cidrsubnet(var.vpc_cidr, 8, 100)
, it generates a subnet with a CIDR of 10.0.100.0/24.
Main.Tf
resource "aws_subnet" "public_subnet_1" {
vpc_id = var.vpc_id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 100)
availability_zone = var.availability_zone_names[0]
tags = {
Name = "public-subnet-1"
Terraform = "true"
}
}
resource "aws_subnet" "public_subnet_2" {
vpc_id = var.vpc_id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 200)
availability_zone = var.availability_zone_names[1]
tags = {
Name = "public-subnet-2"
Terraform = "true"
}
}
variables.tf
variable "public_subnets" {
default = {
"public_subnet_1" = 1
"public_subnet_2" = 2
}
}
variable "vpc_cidr" {
description = "The CIDR Block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "vpc_id" {
description = "The ID of the VPC"
type = string
}
variable "availability_zone_names" {
description = "AZs names"
type = list(string)
}
Outputting the both subnet IDs for later use.
output "public_subnet_1_id" {
value = aws_subnet.public_subnet_1.id
description = "The ID of the first public subnet"
}
output "public_subnet_2_id" {
value = aws_subnet.public_subnet_2.id
description = "The ID of the second public subnet"
}
Just as we did with the VPC module, we need to invoke the public_subnets module in our root main.tf
so Terraform knows to build the resource.
We also need to feed in the necessary attributes from other child modules, such as the VPC ID. We’re passing that attribute into the subnet module via vpc_id = module.vpc.vpc_id
. the same also needs to happen for the names of the availability zones which were queried and output by the availability zone module, availability_zone_names = module.availability_zones.availability_zone_names
.
#Deploy the public subnets
module "public_subnets" {
source = "./modules/subnets_public" " <- invoking the public_subnets module
vpc_id = module.vpc.vpc_id " <-passing in the VPC ID from the VPC module
availability_zone_names = module.availability_zones.availability_zone_names <-passing in the AZs
}
Private Subnets
The same process is followed for creating the private subnets,
- Invoking the module in the root main.tf,
- Passing in the required attributes
- Defining those attributes in the variables.tf
- Outputting the IDs of each private subnet for later use.
# Deploy Private private_subnets
module "private_subnets" {
source = "./modules/subnets_private" <- invoking the private_subnets module
vpc_id = module.vpc.vpc_id <-passing in the VPC ID from the VPC module
availability_zone_names = module.availability_zones.availability_zone_names <-passing in the AZs
}
variables.Tf
variable "private_subnets" {
default = {
"private_subnet_1" = 1
"private_subnet_2" = 2
}
}
variable "vpc_cidr" {
description = "The CIDR Block for the VPC"
type = string
default = "10.0.0.0/16"
}
Here we define the VPC ID that we passed in above
variable "vpc_id" {
description = "the ID of the VPC"
type = string <-type = string means the variable contains only 1 of variable.
}
And here we define the availability zones that we passed in above
variable "availability_zone_names" {
description = "AZs names"
type = list(string) <-type = list(string) means the variable contains a list of variables.
}
Main.Tf
resource "aws_subnet" "private_subnet_1" {
vpc_id = var.vpc_id
cidr_block = cidrsubnet(var.vpc_cidr, 8, var.private_subnets["private_subnet_1"])
availability_zone = var.availability_zone_names[0] <- the [0] means take the first variable in the list
tags = {
Name = "private-subnet-1"
Terraform = "true"
}
}
resource "aws_subnet" "private_subnet_2" {
vpc_id = var.vpc_id
cidr_block = cidrsubnet(var.vpc_cidr, 8, var.private_subnets["private_subnet_2"])
availability_zone = var.availability_zone_names[1]<- the [1] means take the second variable in the list
output "private_subnet_1_id" {
value = aws_subnet.private_subnet_1.id
description = "The ID of the first private subnet"
}
output "private_subnet_2_id" {
value = aws_subnet.private_subnet_2.id
description = "The ID of the second private subnet"
}
EC2
Now we have subnets lets deploy some EC2 instances to them, well only to the public subnets, the private subnets host a RDS Databases.
Invoking the EC2 module,Passing in the public subnet IDs, the security_group and the public key.
module "EC2" {
source = "./modules/EC2"
public_subnet_1_id = module.public_subnets.public_subnet_1_id
public_subnet_2_id = module.public_subnets.public_subnet_2_id
ec2_sg = module.security_groups.ec2_sg
public_key = module.ssh_keys.public_key
}
main.Tf
resource "aws_key_pair" "EC2_ssh_key" {
key_name = "ec2_ssh_key"
public_key = var.public_key
}
resource "aws_instance" "web_server_1" {
ami = var.ubuntu_ami_id
instance_type = "t2.micro"
key_name = aws_key_pair.EC2_ssh_key.key_name
subnet_id = var.public_subnet_1_id <- Asigning the public subnet ID to the EC2 Instance
vpc_security_group_ids = [var.ec2_sg]
tags = {
Name = "Web_Server_1"
}
}
resource "aws_instance" "web_server_2" {
ami = var.ubuntu_ami_id
instance_type = "t2.micro"
key_name = aws_key_pair.EC2_ssh_key.key_name
subnet_id = var.public_subnet_2_id
vpc_security_group_ids = [var.ec2_sg] <- Asigning the Security Group to the EC2 Instance
tags = {
Name = "Web_Server_2"
}
}
variable.tf
defining the Public Subnet IDs we passed in.
variable "public_subnets" {
default = {
"public_subnet_1" = 1
"public_subnet_2" = 2
}
}
variable "public_subnet_1_id"{
description = "Subnet ID for Public Subnet 1"
type = string
}
variable "public_subnet_2_id"{
description = "Subnet ID for Public Subnet 2"
type = string
}
variable "ubuntu_ami_id" {
description = "AMI ID for EC2 "
default = "ami-0611295b922472c22"
type = string
}
defining the Security group that we passed in.
variable "ec2_sg" {
description = "EC2 Security group ID"
type = string
}
variable "public_key" {
description = "The public SSH key for the EC2 instance"
type = string
sensitive = true
}
Conclusion
Throughout this blog post, we’ve explored the process of deploying WordPress on a 2-Tier AWS Architecture using Terraform. This approach combines the power of Infrastructure as Code (IaC) with the robust and scalable services offered by AWS, resulting in a resilient and easily manageable WordPress hosting solution.
Key takeaways from this project include:
- Modular Terraform Structure: By organizing our Terraform code into modules, we’ve created a more maintainable and scalable infrastructure definition. This modular approach allows for easier updates, troubleshooting, and potential expansion of the infrastructure in the future.
- 2-Tier Architecture Benefits: The separation of the presentation layer (EC2 instances in public subnets) from the data layer (RDS in private subnets) provides enhanced security and scalability for our WordPress deployment.
- AWS Resource Integration: We’ve utilized various AWS services such as VPC, EC2, RDS, and security groups, demonstrating how these components can be orchestrated to create a comprehensive hosting environment.
- Infrastructure as Code Principles: This project showcases the power of IaC, allowing for version-controlled, repeatable, and automated infrastructure deployments.
- Practical Application of Terraform: By implementing this project, we’ve gained hands-on experience with Terraform concepts such as modules, data sources, variables, and outputs.
While this blog post doesn’t provide a step-by-step guide, it offers valuable insights into the thought process and structure behind creating a modular Terraform configuration for a real-world application deployment.