Terraform – Very basic AWS website

New VPC, Public Subnet & a Web Site

Requirements & installation of Terraform

The following must be installed and configured for this exercise:

Install AWS CLI

Configure AWS CLI

Install Terraform

Note:  You don't have to install these requirements into your desktop.  It is certainly quite feasible to use a virtual desktop for your development environment using tools like Oracle's virtualbox or VMware Workstation or Player, or Mac Fusion or Mac Parallels.  Perhaps an AWS Workspace or AWS Cloud 9 environment. 

We’ll create a very simple website using Terraform. It’s not really good from a production perspective, except to give a rudimentary and easy to read example of provisioning infrastructure and a website using Terraform.

I have placed all of the code in a GitHub, if you are not into typing all of the code. Her is the link: One_Public_Subnet_Basic_Web_Server

First setup a new folder. You can either use GIT to clone the code from GitHub or type in create your own files as show below:

VPC.tf

This file will create a VPC, we’ll give it a name, mark it as a “Test” environment and create one public Subnet and an Internet Gateway so that we can get Internet traffic in and out of our new AWS network.

So first a bit of code to create the VPC

resource "aws_vpc" "my-vpc" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = {
    Name  = "My VPC"
    Stage = "Test"
  }
}

It states that it is an AWS_VPC, then we provide the VPC IP address range.

A resource block declares a resource of a given type (“aws_vpc”) with a given local name (“my-vpc”). The name is used to refer to this resource from elsewhere in Terraform coding.
The resource type and name together serve as an identifier for a given resource and so must be unique within a module.
Within the block body (between { and }) are the configuration arguments for the resource itself. Most arguments in this section depend on the resource type.

Add a few Tags to most of your terraform resources, it is an excellent way of tracking AWS infrastructure and resources. Not really a big deal if this was to be the only VPC and a few resources. However “TAGS” become really important as an organization might have multiple test environments, multiple Development and QA environments and multiple production environments. By setting tags we can keep track of each project, the type of environment and recognizable names for the many systems. So a standard practice of adding meaningful tags, is a really good idea!

A bit of code to create an Internet Gateway

resource "aws_internet_gateway" "my-igw" {
  vpc_id = aws_vpc.my-vpc.id
  tags = {
    Name = "My IGW"
  }
}

We are coding a resource as a “aws_internet_gateway” and the reference name of “my-igw”. You can provide any name you wish to use. Just know that if you are going to make a reference to the internet gateway in any other terraform code, you must use the exact same name (referenced names are case sensitive and symbols like dash versus underscore sensitive).

ADD One public Subnet

resource "aws_subnet" "public-1" {
  vpc_id                  = aws_vpc.my-vpc.id
  map_public_ip_on_launch = true
  availability_zone       = var.public_availability_zone
  cidr_block              = var.public_subnet_cidr

  tags = {
    Name  = "Public-Subnet-1"
    Stage ="Test"
  }
}

Add route to internet gateway

resource "aws_route_table" "public-route" {
  vpc_id = aws_vpc.my-vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.my-igw.id
  }
  tags = {
    Name = "Public-Route"
  }
}

Associate the route to Internet Gateway to the public subnet

resource "aws_route_table_association" "public-1-assoc" {
  subnet_id      = aws_subnet.public-1.id
  route_table_id = aws_route_table.public-route.id
}

That completes the VPC.TF file


Variables.tf

The variables file for Terraform can actually have almost any name, names like vars.tf, my-vars.tf, my-variables.tf. You can even embed the variables within the VPC.TF file if you so desire, so long as the variables are declared in a file within the same folder. The most important element to learn is not just about the variables, but keeping sensitive variable data secure. Sensitive data should go into a file like “tvars.data”. And add “tvars.data” into the .gitignore file so that our sensitive variables doesn’t get posted in public github repository. Additionally, Hashicorp has a product offering called “Vault”. If multiple personnel are using the same Test, Development, QA or production environment, it is a recommended practice to protect sensitive variable data like AWS credentials, AWS Key names, and other sensitive data!

This is a very basic, non-production example with no sensitive data, so in this case we can create a variables.tf file without worry about keeping any data safe.

variable "region" {
    type=string
    description="AWS region for placement of VPC"
    default="us-west-1"
}

variable "vpc_cidr" {
    type=string
    default="10.0.0.0/16"
}

variable "public_subnet_cidr" {
    type=string
    default="10.0.1.0/24"
}

variable "public_availability_zone"{
    type = string
    default="us-west-1a"
}

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

That completes the variables file


Main.tf

Once again the name the name of the file is not important. We could call it MyWeb.tf or Web.tf. We could even put the VPC code, the variables code and the Web code, (all of the code), into one big file. Breaking up the code into separate files, just makes it modular coding that is reusable and easier to review.

provider "aws" { region = var.region}

Notice we are declaring the AWS Region in this block of code. WHAT? Shouldn’t this be declared when we created the VPC itself? Again, as long as it is declared, it almost doesn’t matter which file you place the declaration of AWS Region.

Notice also in this short bit of code:

We are stating the provider as “AWS”, this tells Terraform the backend code that will be downloaded from Hashicorp repositories in support of this instance of Terraform provisioning. It might also be a good idea to include the release of Terraform as a requirement within the code. Over time, Hashicorp changes and deprecated elements of Terraform. Such that over time, your code may no longer work if you pull down the “latest Terraform backend” from Hashicorp repositories.

Versioning Terraform Code

Code similar to the following might be a good idea:

terraform {required_version = ">= 1.04, < 1.05"}

This stipulates the use of Terraform version “1.04”, which is a representation of the version utilized when the code was tested and released. Future versions of Terraform may not work because of deprecation, but this version for sure works because it was tested using Terraform version 1.04.

I have not included this statement in my code, because after all, it is simply an example, not coding for any project or production system. We shall see if over time, something changes and it no longer works 🙂

Using SSM parameter to obtain AMI ID

data "aws_ssm_parameter" "ubuntu-focal" {
  name = "/aws/service/canonical/ubuntu/server/20.04/stable/current/amd64/hvm/ebs-gp2/ami-id"
}

You will see a tremendous amount of “Infrastructure as Code” declaring the AWS Image ID to use for an EC2 resource as something like ami-0d382e80be7ffdae5 for example. Sometimes it is hardcoded into the “aws_instance” block, or most times you’ll see it declared as a variable.

Sometimes a “Infrastructure as Code” creates a mapping (a list) of images where one of the images can be used dependent on the region. I’ve seen code where literally an AMI-ID is listed for each AWS region across the globe. Not unlike a phone book listing. This type of approach is used in Terraform, Cloudformation, Ansible, Chef and Puppet, most anywhere with provisioning with Infrastructure as Code.

This type of mapping of an ID per region, might be required. If for example, creating a custom “Golden Image”. It is not unusual to create and release an AMI ID as the gold standard to use for a deployment. The “Golden Image” is pre-configured with a specific version of Python, Apache or NGINX for example. The custom image is then stored as an EC2 AMI in AWS. To use as the AMI ID for a specific project(s) and you’ll need a different image ID depending on the region.

I have already created and will be posting in the near future, examples of scalable web servers. Using a custom AMI image with specific versions of Python, Apache2 and another AMI for MySQL backends. In those examples, I will be using a specific “golden image” with versioning and release statements.

For now though, I just need the latest version of Ubuntu server. You can see a good write up on how to pull a specific Ubuntu image. You’ll find the document by Ubuntu, at this link: Finding-ubuntu-images-with-the-aws-ssm-parameter-store.

This method is a Terraform code that connects into AWS API to “Get Data”. In this case an aws_ssm_parameter. And specifically in this case getting an image for Ubuntu server 20.04 stable release.

This bit of code will get the AMI ID, for the AWS Region specified earlier.

I could’ve just as easily have gotten an Amazon Linux 2 AMI ID as follows:

data "aws_ssm_parameter" "linuxAmi" {
  name     = "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
}

Caution: Do not use an Amazon Linux example above because the bootstrap.sh, User Data (see below) is specifically using Ubuntu server commands language . Make sure to sure to use the UBUNTU SSM Parameter above. I am simply demonstrating that you can get other Linux kernels, using the same process.

Creating the aws_instance

resource "aws_instance" "web" {
  ami                    = data.aws_ssm_parameter.ubuntu-focal.value
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public-1.id
  vpc_security_group_ids = ["${aws_security_group.web.id}"]
  user_data = file("bootstrap.sh")
  tags = {
    Name  = "Basic-Web-Server"
    Stage = "Test"
  }
}

Now we are calling for the creation of an AWS Instance with the name “web”

In the AWS resource block, we’ll need to stipulate at the very least an AMI-Id, the instance type, the subnet placement and a security group.

In this case we are using the AMI-Id pulled earlier. ami=data.aws_ssm_parameter.ubuntu_focal.value. Where “ubuntu_focal” is referencing the bit of code that pulls the code from AWS.

 data "aws_ssm_parameter" "ubuntu-focal" (this pulls the AMI-ID data from AWS - line 8 of this code)
 ami= data.aws_ssm_parameter.ubuntu-focal.value (this uses the AMI-ID Value that was pulled from AWS in line 8)

The instance type will be a t2.micro (free tier) referenced in the variables.tf file. The subnet references the subnet created in VPC.tf that is named “public-1”. The security group is referencing the security group created in “security_groups.tf” (see the next section below).

User Data

User data is a bit of code that executes within the AWS Instance itself. In this case, code the Web server executes when it is first built by Terraform provisioning. There is a number of ways to write this script which executes when the AMI instance is launched. We could write the script like this:

  user_data              = <<-EOF
                            #!/bin/bash
                            apt update
                            apt upgrade -y
                            hostnamectl set-hostname Web                            
                            EOF

Or we can put the script into a file and call the file itself like this:

user_data = file("bootstrap.sh")

For this example we are using the “bootstrap.sh” example. Technically we can use any name, so long as the script itself is properly coded. We could use “boot.sh” for example.

Add some tags and we are near complete with this file.

The final lines, is an instruction for Terraform to output AWS data. The Terraform code use the AWS API to pull data about our new Web server and display that data in the terminal when Terraform completes provisioning our infrastructure. In this case we want only the public IP

output "web" {  value = [aws_instance.web.public_ip] }

Note: If you leave off the last bit, “public_ip” the output will display all of the known data about the new web server. However, as can be seen in future examples, being specific about output data makes it referenceable in other Terraform modules. So in this case we want the public_IP.

That completes the Main.tf file


Lastly, Create the security_groups.tf file

Security groups resource “aws_security_group” at the very least requires a name, in this case “web-sg”, the vpc_id and an ingress rule and egress rule. Once again, about the Name of the resource, it is important to remember, that referencing the security group the name itself is case sensitive and it symbol sensitive like dash instead of underscore.

resource "aws_security_group" "web-sg" {
  vpc_id      = aws_vpc.my-vpc.id
  description = "Allows HTTP"

  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"]
  }
  tags = {
    name  = "SecurityGroup-Web"
    Stage = "Test"
  }
}

Configuration

Note: The variables do not have to be changed if you are ok with running a new VPC and Web server out of US-West-1 region

Once the requirements stated above are installed, and the VPC.tf, main.tf, security_groups.tf and variables.tf are created in the same folder you are ready to launch. Or, you can simply clone the GITHUB repository into a folder.

  • Edit the variable for your choice for AWS Region (currently, the default is “us-west-1”).
  • Edit the CIDR blocks if you want to use different address range for your new VPC
  • Edit the Instance type if you want to use a different instance type (note t2.micro is the only one you can use for free tier)

Launching the VPC and Web Server

After installing the requisite software, requisite files and configured the variables.

Run the following commands in terminal

  • Terraform init
    • Causes terraform to install the necessary provider modules, in this case to support AWS provisioning
  • Terraform validate
    • Validates the AWS provisioning code
  • Terraform Apply
    • Performs the AWS provisioning of VPC and Web Server

After Terraform finishes provisioning the new VPC, Security Group and Web Server, it will output the Public IP address of the new public server in the terminal Window


Open a browser and you should see the welcome to nginx as shown below:


Clean up

Once you have finished with this example run the following command:

  • Terraform Destroy (to remove VPC and Web Server)

It goes without saying, but it has to be said anyway. This is not for production!

All public websites should have some type of application firewall in between the Web Server and its internet connection!

It is a good idea to remove an EC2 instance when you are finished with the instance, so as not to incur costs for leaving an EC2 running.