Part 10— Terraform Final Project — HumanGov Application: Deployment of reusable SaaS multi-tenant AWS infrastructure using Terraform modules and AWS CodeCommit

Cansu Tekin
12 min readDec 18, 2023

--

HumanGov is a software-as-a-service (SaaS) cloud company that will create a Human Resources Management SaaS application for the Department of Education across all 50 states in the US and host the application files and databases in the cloud. This is the final part of a project series. Check Part 1, Part 2, Part 3, Part 4, Part 5, Part 6, Part 7, Part 8 and Part 9.

HumanGov is a SaaS (Software as a Service) web application. As a DevOps engineer, we will configure the infrastructure that will host the application and data. This project aims to provide a flexible, scalable, and secure infrastructure for deploying the HumanGov SaaS application. The main goal is to create a seamless, efficient, and cost-effective method for deploying and managing a SaaS application on AWS.

The infrastructure will be based on AWS services such as EC2 instances, DynamoDB database, and S3 buckets. We will use AWS CodeCommit to ensure secure storage of Terraform configuration files.

Part 1: Creation of Terraform module to deploy the infrastructure

The HumanGov web application will be hosted in the EC2 instance. We will also need to manage Human Resource employee data for each state. To efficiently store this data, a DynamoDB database will be used. Finally, S3 buckets will be established to store driving licenses uploaded by employees from each state. Consequently, a Terraform module will be developed to systematically provision all necessary infrastructure. This modular approach ensures reusability, enabling the application of identical infrastructure configurations across each state. We will be able to configure the infrastructure for 50 states without duplicating the code using Terraform modules. In Part 1, we will design a Terraform module to create the EC2 instance, DynamoDB database, and S3 bucket resources.

Step 1: Connect to the new AWS CodeCommit repository(human-gov-infrastructure) to securely store Terraform configuration files

Go to AWS Services and open Cloud9. Move to the project directory named human-gov-infrastructure. We will create configuration files inside this repository. We created this directory in the Part 2. This is an AWS CodeCommit repository. It has a Git repository in it. You can check it on AWS:

When you move this repository it will show the branch you are in which is the master for right now.

Step 2: Designing a Terraform module that creates and configures AWS resources such as EC2 instances, DynamoDB database, and S3 buckets

Read Part 7 to learn more about Terraform modules before starting if you need more information.

Create a root directory folder to store Terraform files here and then create a new directory called modules/aws_humangov_infrastructure to hold the module files. We will use this module to deploy the set of resources for each state. We will have only one module that will deploy the EC3 instance, S3 bucket, and DynamoDB table at once for each state. Inside the modules/aws_humangov_infrastructure directory, create the variables.tf main.tf and outputs.tf empty module files:

mkdir terraform
cd terraform
mkdir -p modules/aws_humangov_infrastructure
cd modules/aws_humangov_infrastructure
touch variables.tf main.tf outputs.tf

Step 3: Edit the module configuration files; variables.tf, main.tf, and outputs.tf files

variables.tf

For now, we will have only one variable, we can add new variables whenever we need them during the project. This one variable will hold the state name. When we are creating resources on AWS we may need to associate the name of each state with a tag. In our case, if we use the state name within a tag we can easily figure out all the resources will be used for the given state.

variable "state_name" {
description = "The name of the US State"
}

main.tf

This file will include all the resources that we need; EC2 instances, DynamoDB database, and S3 buckets.

Security group for the EC2 instance: First, we will specify a security group for the EC2 instance that we will create. In this security group, we will determine the ports that are made available to users to allow them to access the application running inside the EC2 instance. Then we can connect to this EC2 instance with SSH. We are going to do this for each state and use interpolation (${var.state_name}) to name security groups for each state. The state name will come from the variable.tf file. Port 22 is for SSH connectivity and port 80 is for HTTP. We will allow the traffic to our application from everywhere with 0.0.0.0/0 CIDR-block in this hands-on. However, give access to only the network you trust for the production environment. We also tag resources by the state name that belongs to(humangov-${var.state_name}).

resource "aws_security_group" "state_ec2_sg" {
name = "humangov-${var.state_name}-ec2-sg"
description = "Allow traffic on ports 22 and 80"

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"]
}

tags = {
Name = "humangov-${var.state_name}"
}
}

EC2 instance: Check Part 5 on how to get AMI and create resources. While creating an EC2 instance, we will provide an SSH key. EC2 instances can be provisioned with the SSH connection. We are just specifying the key inside the resource here. We will create the key later. All the EC2 instances we will provision will use this key.

resource "aws_instance" "state_ec2" {
ami = "ami-0230bd60aa48260c6"
instance_type = "t2.micro"
key_name = "humangov-ec2-key"
vpc_security_group_ids = [aws_security_group.state_ec2_sg.id]

tags = {
Name = "humangov-${var.state_name}"
}
}

DynamoDB table: Next, we are going to create a DynamoDB table for each state inside the DynamoDB database on AWS. A hash_key and attribute name are the ID of the DynamoDB table column.

resource "aws_dynamodb_table" "state_dynamodb" {
name = "humangov-${var.state_name}-dynamodb"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"

attribute {
name = "id"
type = "S" #Type String
}

tags = {
Name = "humangov-${var.state_name}"
}
}

S3 bucket name: Before creating the S3 bucket itself we will create its 4-length name dynamically without any special character and uppercase letter.

resource "random_string" "bucket_suffix" {
length = 4
special = false
upper = false
}

S3 bucket:

resource "aws_s3_bucket" "state_s3" {
bucket = "humangov-${var.state_name}-s3-${random_string.bucket_suffix.result}"
acl = "private"

tags = {
Name = "humangov-${var.state_name}"
}
}

outputs.tf

We are going to output the public DNS of the EC2 instance, DynamoDB table name, and S3 bucket name. We will use this public hostname to test the application running on the EC2 instance. Their values come from main.tf. Check the file to make sure the naming is correct.

output "state_ec2_public_dns" {
value = aws_instance.state_ec2.public_dns
}

output "state_dynamodb_table" {
value = aws_dynamodb_table.state_dynamodb.name
}

output "state_s3_bucket" {
value = aws_s3_bucket.state_s3.bucket
}

Step 4: Create and edit the root directory (terraform) files; main.tf, variable.tf, and output.tf

First, we will move to the outside of the module directory which is the terraform directory we created before. Create files;

Right click on the terraform directory -> New File -> name new file

main.tf

Here, we will provide a cloud provider first. In our case it is AWS. Then we call the module aws_humangov_infrastructure we created before. In for_each attribute, we will pass the variable states (var.states) inside variable.tf file. Therefore, we will create resources for each state determined inside the state list. If we have 3 states in the list the module will be executed 3 times. The state_name will store the state name whose infrastructure is under configuration at that time. As a result, we do not need to create all these resources manually and by one for each state. Instead, we take advantage of the module structure.

provider "aws" {
region = "us-east-1"
}

module "aws_humangov_infrastructure" {
source = "./modules/aws_humangov_infrastructure"
for_each = toset(var.states)
state_name = each.value
}

variable.tf

We will specify a list of states that we want to configure. We will list only 3 state names we want to configure infrastructure like [“california", “florida”, “texas”] for now to save time. When we run the Terraform, the module block in main.tf as mentioned above will loop over the state list in the states variable block to create resources for each state in the list. If we want to create the same infrastructure for another state we only need to update adding it to this list. Everything will be the same.

variable "states" {
description = "A list of state names"
default = ["california", "florida", "texas"]
}

output.tf

We already have output.tf file inside the module, however, it is in the scope of the module and can not be read directly out of its scope. It is not going to be printed outside of the module. We should call its values inside outputs.tf file inside the root directory. The structure below allows us to group created resources by state name (state =>) through a loop and display the information for each state.

output "state_infrastructure_outputs" {
value = {
for state, infrastructure in module.aws_humangov_infrastructure :
state => {
ec2_public_dns = infrastructure.state_ec2_public_dns
dynamodb_table = infrastructure.state_dynamodb_table
s3_bucket = infrastructure.state_s3_bucket
}
}
}

Step 5: Create the SSH key humangov-ec2-key and upload the private key humangov-ec2-key.pem to Cloud9

This key name has to match the key name(humangov-ec2-key) we determined inside the Terraform EC2 configuration block inside the module main.tf file. We will use this SSH to connect to the EC2 instance later.

AWS Services -> EC2 -> EC2 Dashboard -> Network & Security -> Key Pairs -> Create key pairs

Step 6: Run Terraform

terraform fmt #check format of the files
terraform init #initialize Terraform
terraform validate #validate is everything is correct
terraform plan #See what is Terraform configuration plan
terraform apply #Apply all configuration

We determined 5 resources in module main.tf file. We will have a total of 15 resources created by Terraform for 3 states. All these resources will be created at the same time.

Outputs are also created for each state.

Step 7: Go to AWS and check the created resources there

EC2 instances:

DynamoDB tables:

S3 buckets:

Step 8: Destroy the resources

If you are doing only for learning practices do not forget to destroy the resources you created. Otherwise, AWS keeps you charging for running resources.

terraform destroy

Part 2: Migrate the Terraform state file to a remote backend; the S3 bucket, and use the DynamoDB table for state-locking

If we remove the terraform state file accidentally all the information regarding to infrastructure will be gone. We will migrate the Terraform state file running locally on the Cloud9 instance to an S3 bucket and use the DynamoDB table for state locking to ensure that only one user can make changes to the infrastructure at a time. State locking helps prevent concurrent modification of the state file, which can lead to corruption. Check Part 8 to learn more about the Terraform state file.

This is our state file stored on Cloud9 after we ran Terraform in the previous part.

Step 1: Create a new S3 bucket to store the Terraform state remotely

We are not going to create an S3 bucket using the Terraform at this time. Sometimes we destroy resources created with Terraform using terraform destroy. If we create an S3 bucket to store the state file inside the terraform configuration then if we destroy resources the state file also be destroyed with the S3 bucket. We are going to use AWS CLI to create an S3 bucket and DynamoDB table in this case.

Name the bucket uniquely for yourself.

aws s3api create-bucket --bucket humangov-terraform-state-ct2023 --region us-east-1

Step 2: Create the DynamoDB table manually

aws dynamodb create-table \
--table-name humangov-terraform-state-lock-table \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \
--region us-east-1

Step 3: In your root project directory, create a backend.tf file in your project directory

A backend.tf file is used to separate the backend configuration from the main Terraform configuration. It specifies whether the Terraform state is stored locally or in a remote backend. In our case, we are going to use an S3 bucket specifying the details of the S3 bucket. Read more here.

Note that the backend.tf configuration also specifies a DynamoDB table (terraform-lock-table).

cd human-gov-infrastructure/
cd terraform/
touch backend.tf

backend.tf

terraform {
backend "s3" {
bucket = "humangov-terraform-state-ct2023"
key = "terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "humangov-terraform-state-lock-table"
}
}

Step 4: Initialize Terraform and apply the Terraform configuration

All resources we destroyed in Part 1 will be created again and the state file will be stored in the S3 bucket this time differently than Part 1.

The state file is stored inside the S3 bucket here.

DynamoDB table

Step 5: Destroy the files and check the state file inside the S3 bucket

terraform destroy

While destroying infrastructure you may realize that there is no lock file inside terraform directory on Cloud9. The lock file will be stored in the DynamoDB table. That will show who is making changes and IP addresses.

The state file is safe and inside the S3 bucket even after destroying resources.

We still have a state file locally but nothing will be stored in it. Whatever configuration changes we do will be stored in the S3 bucket.

When we ran terraform init in this part Terraform did not ask for any confirmation to migrate the state file because the state file did not have any resources created at that time. It was simply empty. So, there was nothing to migrate to the remote backend. It initialized a state file inside the S3 bucket and put changes directly in it instead of migrating from the local directory.

We can run terraform show to see created resources. That information comes from the state file inside the S3 bucket. Even though we removed the local terraform.tfstate file terraform show command still displays the information.

Part 3: Push Terraform configuration files to the AWS CodeCommit repository

In this part, we are going to push configuration files to the AWS CodeCommit which has a git repository inside that allows us to store files securely and to collaborate with other team members.

Step 1: Add Terraform files to the local git repository, commit, and push them to AWS CodeCommit

We already created the human-gov-infrastructure repository in AWS CodeCommit in Part 2. We are going to store infrastructure-related files in this repository.

AWS Services -> CodeCommit -> Repositories

We are not going to push all the files to the AWS CodeCommit repository. We are going to exclude some files(especially hidden files) because we need them only locally. We are going to add a .gitignore file to our repository to exclude sensitive information and temporary files generated by Terraform from being tracked. Some common entries to include in your .gitignore file are:

.terraform/
*.tfstate
*.
tfstate.backup
*.tfvars
*.tfplan
*.tfr
*.tfstate.lock.info

Our git local repository is human-gov-infrastructure. It has a .git folder in it. We are going to create a .gitignore file here.

Add Terraform files to the local git repository:

Those files are at the staging area for now and waiting to be committed. You can read more about it in Part 2.

Commit and push them to AWS CodeCommit:

You should see all pushed files inside the CodeCommit repository now.

CONGRATULATIONS!!!

--

--

Cansu Tekin

AWS Community Builder | Full Stack Java Developer | DevOps | AWS | Microsoft Azure | Google Cloud | Docker | Kubernetes | Ansible | Terraform