Programmable infrastructure allow professionals to manage on-premises and cloud resources through code instead of management platforms and manual methods traditionally used by IT teams such as the AWC CLI or AWS Console.
Infastructure As Code (IaC) is an IT practice of codifying and mananging IT infastructure as software/code. Terraform interacts with cloud and on-prem Application Programming Interfaces (APIs) to configure infastructure such as EC2 instances, Virtual Private Cloud (VPC) and Security Groups. IaC allows for version control and fine tuned configuration management for any organization’s IT posture. Terraform works through a series of configuration files .tf
written in HashiCorp Configuration Lanuage (HCL). These .tf
files work in concert to define the resources required and allow for seamless changes based on need.
This demo will create the following resources in AWS for a “development” environment and a “production” environment.
- VPC
- Public & Private Subnet
- NAT Gateway
- Internet Gateway
- Key-Pair (SSH Key)
- SSH Security Group (needed to ssh into the demo instance)
- AWS EC2 Instance
Prerequisites:
- Preferred Linux OS (I am using Manjaro for this demo)
- AWS Account & Command Line Interface w/ access key and secrets
- You may use hard coded AWS credentials in your
.aws
directory or utilizeaws-vault
to store access keys and secrets securely.
- You may use hard coded AWS credentials in your
- Git
- Basic knowledge of Docker and web apps -(Optional) Text Editor or IDE such as VSCode
Terraform Environment:
Install terraform via your preferred package manager:
# Debian/Ubuntu:
sudo apt-get update && sudo apt-get install -y gnupg software-properties-common curl
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
sudo apt-get update && sudo apt-get install terraform
#Arch/ Manjaro:
sudo pacman -S terraform
Once installed verify install:
terraform -help
Clone the demo GitHub repository and cd into the directory:
git clone https://github.com/alexrf45/aws-terraform-practice.git
cd aws-terraform-practice
Below is a breakdown of the provided files in the directory:
.
├── demo
│ ├── docker.sh
│ ├── ec2_instance.tf
│ ├── ec2_security_group.tf
│ ├── outputs.tf
│ ├── provider.tf
│ ├── vars.tf
│ └── vpc_creation.tf
├── dev.auto.tfvars
├── prod.auto.tfvars
├── provider.tf
└── README.md
docker.sh
A simple bash script that installs docker and enables non-root permissions. Setting up scripts for use in config files can help provide a consistent environement for developers and administrators.
#! /bin/bash
sudo apt-get update
sudo apt-get upgrade
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
sudo usermod -aG docker ubuntu
ec2_instance.tf
The config file for creating the demo EC2 Instance.
resource "aws_key_pair" "demo_ssh_key" {
key_name = var.key_name
public_key = file("~/.ssh/demo-key.pub")
}
resource "aws_instance" "terraform_ec2" {
ami = var.ami
instance_type = var.instance_type
associate_public_ip_address = "true"
key_name = var.key_name
vpc_security_group_ids = [aws_security_group.terraform-ssh.id]
subnet_id = aws_subnet.dev-public-subnet.id
user_data = file("docker.sh")
tags = var.resource_tags
}
ec2_security_group.tf
This config files creates a simple security group for enabling SSH access to our demo EC2 instance.
resource "aws_security_group" "terraform-ssh" {
name = "terraform-ssh-sg"
description = "Allow SSH inbound traffic"
vpc_id = aws_vpc.dev.id
ingress {
description = var.description
from_port = 22
to_port = 22
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"]
ipv6_cidr_blocks = ["::/0"]
}
tags = var.resource_tags
}
outputs.tf
This config file exports outputs from the creation of each resource for use in other resource config files. (This is neccessary to add the security group to the EC2 instance and configure the VPC properly)
output "vpc_id" {
description = "The ID of the VPC"
value = try(aws_vpc.dev.id, "")
}
output "vpc_cidr_block" {
description = "The CIDR block of the VPC"
value = try(aws_vpc.dev.cidr_block, "")
}
output "vpc_arn" {
description = "The ARN of the VPC"
value = try(aws_vpc.dev.arn, "")
}
output "public_subnets" {
description = "List of IDs of public subnets"
value = aws_subnet.dev-public-subnet.id
}
output "private_subnets" {
description = "List of IDs of private subnets"
value = aws_subnet.dev-private-subnet.id
}
output "public_ip" {
description = "The public IP address assigned to the instance, if applicable. NOTE: If you are using an aws_eip with your instance, you should refer to the EIP's address directly and not use `public_ip` as this field will change after the EIP is attached"
value = aws_instance.terraform_ec2.public_ip
}
output "id" {
description = "The ID of the instance"
value = aws_instance.terraform_ec2.id
}
provider.tf
This config files sets the provider as AWS to ensure we are interacting with the AWS API. You can add other providers as needed to keep an easy to reference list for your config files.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.16"
}
}
required_version = ">= 1.2.0"
}
provider "aws" {
region = var.aws_region
}
vars.tf
This config files declares variables needed for each resource config file. This config file also allows us to utilize a .tfvars
file to pass variable values to the resource configuration during run time. A variables file helps reduce the need for manual alteration of config files.
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "resource_tags" {
description = "Tags to set for all resources"
type = map(string)
default = {
project = "test-project",
environment = "dev"
Name = "Development"
}
}
variable "dev_vpc_cidr" {}
variable "public_subnets" {}
variable "private_subnets" {}
variable "ami" {
type = string
default = "ami-052efd3df9dad4825"
}
variable "instance_type" {
description = "EC2 Instance type"
type = string
default = "t2-micro"
}
variable "key_name" {
description = "Name of the associated ec2 key pair"
type = string
default = "dev-demo"
}
variable "description" {
description = "Description of security group"
type = string
default = "SSH"
}
vpc_creation.tf
This config file creates a new VPC network for our demo.
resource "aws_vpc" "dev" {
cidr_block = var.dev_vpc_cidr
instance_tenancy = "default"
enable_dns_support = "true"
enable_dns_hostnames = "true"
tags = var.resource_tags
}
resource "aws_internet_gateway" "dev-IGW" { # Creating Internet Gateway
vpc_id = aws_vpc.dev.id # vpc_id will be generated after we create VPC
}
resource "aws_subnet" "dev-private-subnet" {
vpc_id = aws_vpc.dev.id
cidr_block = var.private_subnets # CIDR block of private subnets
tags = var.resource_tags
}
resource "aws_subnet" "dev-public-subnet" {
vpc_id = aws_vpc.dev.id
cidr_block = var.public_subnets # CIDR block of public subnets
tags = var.resource_tags
}
# Route table for Public Subnet's
resource "aws_route_table" "dev-public-rt" { # Creating RT for Public Subnet
vpc_id = aws_vpc.dev.id
route {
cidr_block = "0.0.0.0/0" # Traffic from Public Subnet reaches Internet via Internet Gateway
gateway_id = aws_internet_gateway.dev-IGW.id
}
tags = var.resource_tags
}
#Route table for Private Subnet's
resource "aws_route_table" "dev-private-rt" { # Creating RT for Private Subnet
vpc_id = aws_vpc.dev.id
route {
cidr_block = "0.0.0.0/0" # Traffic from Private Subnet reaches Internet via NAT Gateway
nat_gateway_id = aws_nat_gateway.NATgw.id
}
tags = var.resource_tags
}
#Route table Association with Public Subnet's
resource "aws_route_table_association" "dev-public-rt-association" {
subnet_id = aws_subnet.dev-public-subnet.id
route_table_id = aws_route_table.dev-public-rt.id
}
#Route table Association with Private Subnet's
resource "aws_route_table_association" "dev-private-rt-association" {
subnet_id = aws_subnet.dev-private-subnet.id
route_table_id = aws_route_table.dev-private-rt.id
}
resource "aws_eip" "devnatIP" {
vpc = true
tags = var.resource_tags
}
#Creating the NAT Gateway using subnet_id and allocation_id
resource "aws_nat_gateway" "NATgw" {
allocation_id = aws_eip.devnatIP.id
subnet_id = aws_subnet.dev-public-subnet.id
tags = var.resource_tags
}
dev.auto.tfvars
.tfvars
files allow us to manage variable assignments systematically in a file with the extension .tfvars or .tfvars.json.
aws_region = "us-east-1"
availability_zone_names = [
"us-east-1d",
"us-east-1e",
"us-east-1f"
]
instance_type = "t2.micro"
key_name = "dev-demo"
ami = "ami-052efd3df9dad4825"
dev_vpc_cidr = "10.0.0.0/16"
public_subnets = "10.0.1.0/24"
private_subnets = "10.0.2.0/24"
prod.auto.tfvars
Same as dev.auto.tfvars
with a few changes to simulate a production environment. We will not use all the variable declared here today.
aws_region = "us-east-1"
availability_zone_names = [
"us-east-1a",
"us-east-1b",
"us-east-1c"
]
network = "10.0.1"
instance_type = "t2.micro"
key_name = "demo-prod"
volume_tags = {
"Name" = "Demo"
"Owner" = "Demo-Prod"
}
ami = "ami-052efd3df9dad4825"
dev_vpc_cidr = "10.0.0.0/16"
public_subnets = "10.0.1.0/24"
private_subnets = "10.0.2.0/24"
A providers.tf
file is maintained in the root directory and can be copied to new resource directories as needed i.e demo/
Each file provides the neccessary declarations to create AWS resources. Let’s prepare our enviornment for running terraform commands:
Terraform Initialize:
In order for terraform to interact with the AWS API, lets set up the demo/
directory for use in terraform:
$ cd demo
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v4.22.0
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
- I have previously ran the command in the
demo
directory so it reads from the .terraform.lock.hcl file which contains the provider (AWS) info stated in theprovider.tf
file.
SSH Access:
In order to access our EC2 instance we need to create a SSH key on our local machine. The ec2_instance.tf
file copies our public key associated with the key for use in creating an AWS Key-pair resource. Skip the prompts to add a passphrase as this key will only be used for the duration of the demo.
ssh-keygen -t ed25519 -C "terraform demo" -f ~/.ssh/demo-key #creates an ssh key in your ssh directory. If you don't have one, create it.
Verify the key and it’s associated public key are on your local machine, you should see two files named demo-key and demo-key.pub:
ls -la ~/.ssh/
Open the ec2_instance.tf
file and verify the aws_key_pair resource reflects the file path of public key. If you created a key with a different name, change it here to ensure it copies the correct public key:
resource "aws_key_pair" "demo_ssh_key" {
key_name = var.key_name
public_key = file("~/.ssh/demo-key.pub")
}
Now we will be able to access the EC2 instance once it is created.
Terraform Validate:
- Once initialized, type
terraform validate
to verify the configuration is valid.
Success! The configuration is valid.
Terraform Plan:
The terraform validate
command ensures the configuration files work together and declare resources appropriately. terraform plan
creates a plan file that is used as the final instructions to create the resources and validates the configuration is correct.
In this demo we are utilizing a .tfvars
file to introduce inputs for variables at the plan phase of deployment (dev.auto.tfvars
) . The .tfvars
file by default is named terraform.tfvars
but that can get confusing with thousands of resources or locally built modules (our demo/ directory is an example of a module that could be referenced in another config file in the root module) Terraform also recognizes .auto.tfvars
.
The terraform plan
command has two useful arguments neccessary for the demo:
-var-file="../dev.auto.tfvars"
- This references the inputs for variables declared in our demo directory/module and passes them along to the configuration as needed.
-out=demo-plan
- This outputs a plan file with the specified name. This is usual for testing or setting up different plan files based on use case. For the demo, name it something simple you can remember.
Let’s run the terraform plan
command:
terraform plan -var-file="../dev.auto.tfvars" -out=demo-plan
You should see alot of output showing resources and changes followed by instructions on how to create the resources at the bottom of your terminal:
You can also review what resources will be created with the
terraform show
command:
terraform show "demo-plan"
Below is an example output of where the ec2_instance.tf
file copies the public key for creating the AWS key pair:
Terraform Apply:
Now that we have created a terraform plan file, we can run terraform apply
to begin creation of the AWS resources:
terraform apply "demo-plan"
Terraform will begin creating resources and provide updates as it begins and finishes creating resources:
Example output:
aws_vpc.dev: Creating...
aws_key_pair.demo_ssh_key: Creating...
aws_eip.devnatIP: Creating...
aws_key_pair.demo_ssh_key: Creation complete after 0s [id=dev-demo]
aws_eip.devnatIP: Creation complete after 1s [id=eipalloc-01501eabaec47d25b]
aws_vpc.dev: Still creating... [10s elapsed]
aws_vpc.dev: Creation complete after 12s [id=vpc-0c858786e38f2f210]
aws_subnet.dev-public-subnet: Creating...
aws_internet_gateway.dev-IGW: Creating...
aws_subnet.dev-private-subnet: Creating...
aws_security_group.terraform-ssh: Creating...
aws_internet_gateway.dev-IGW: Creation complete
Resource creation should take about 2-4 minutes depending on proximity from the us-east-1 AWS region. You should see the following type of output in your terminal (**Resource ARNs, IDs and Public IP will be different):
Apply complete! Resources: 13 added, 0 changed, 0 destroyed.
Outputs:
id = "i-066df87335634ad81"
private_subnets = "subnet-0b1eef62196293730"
public_ip = "100.26.243.84"
public_subnets = "subnet-097d16be921f7a1b3"
vpc_arn = "arn:aws:ec2:us-east-1:710932396715:vpc/vpc-0c858786e38f2f210"
vpc_cidr_block = "10.0.0.0/16"
vpc_id = "vpc-0c858786e38f2f210"
Verify EC2 SSH Access:
Now we can SSH into our EC2 instance and verify Docker is installed as referenced in user_data arguement in the ec2_instance.tf
file:
ssh -i ~/.ssh/demo-key ubuntu@PUBLICIP
Testing EC2 functionality using Docker:
Lets’ pull down a simple REST API container and run it:
In your ssh terminal clone this repo down and cd into the directory:
git clone https://github.com/eaccmk/node-app-http-docker
cd node-app-http-docker
Repo contents:
ubuntu@ip-10-0-1-88:~/node-app-http-docker$ tree .
.
├── Dockerfile
├── LICENSE
├── README.md
├── app.js
├── controller.js
├── data.js
├── package.json
└── utils.js
0 directories, 8 files
First let’s build the docker image (this take about 1-2 minutes):
docker build -t rest-api .
Verify the image was created:
docker image ls
ubuntu@ip-10-0-1-88:~/node-app-http-docker$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
rest-api latest 46d5f173ab6d 28 seconds ago 907MB
node 16 c6b745e900c7 5 days ago 907MB
Now let’s run the container:
docker run --name restapi --rm -d -p 8080:8080 rest-api:latest
We can test the REST API works by using the curl command:
curl http://localhost:8080
ubuntu@ip-10-0-1-88:~/node-app-http-docker$ curl http://localhost:8080
Welcome, this is your Home page
We can also test the 404 response and a health check:
curl http://localhost:8080/404
ubuntu@ip-10-0-1-88:~/node-app-http-docker$ curl http://localhost:8080/404
{"message":"Route not found"}
curl http://localhost:8080/health
ubuntu@ip-10-0-1-88:~/node-app-http-docker$ curl http://localhost:8080/health
{"uptime":209.060012355,"message":"OK","timestamp":1658095518470}
Clean Up & Terraform Destroy:
Let’s stop the container and Log out of our EC2 instance:
docker container stop restapi
exit
Before we destory our Terraform resources let’s review the created resources:
terraform state list
terraform state
displays our resources by their resource name as specified in the .tf files. You should see the following resource IDs below. This is super helpful for defining resources and referencing them.
Example output:
aws_eip.devnatIP
aws_instance.terraform_ec2
aws_internet_gateway.dev-IGW
aws_key_pair.demo_ssh_key
aws_nat_gateway.NATgw
aws_route_table.dev-private-rt
aws_route_table.dev-public-rt
aws_route_table_association.dev-private-rt-association
aws_route_table_association.dev-public-rt-association
aws_security_group.terraform-ssh
aws_subnet.dev-private-subnet
aws_subnet.dev-public-subnet
aws_vpc.dev
terraform destory
looks at the terraform state and plan to decide on what resources to destory. We willl also reference the dev.auto.tfvars
file during command execution to ensure resources are destroyed correctly.
terraform destroy -var-file="../dev.auto.tfvars"
Terraform will ouput a list of resources and their changes (destroy) and a prompt to confirm deletion. type yes and press enter.
Example output:
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value:
Resource teardown should take about 3-4 minutes.
Example output:
aws_internet_gateway.dev-IGW: Destruction complete after 3m41s
aws_vpc.dev: Destroying... [id=vpc-0c858786e38f2f210]
aws_vpc.dev: Destruction complete after 0s
Destroy complete! Resources: 13 destroyed.
Conclusion:
Today we created AWS resources using Terraform and sprinkled in a litle Docker for fun. Stay tuned as we integrate version control with Terraform to make updating our infastructure as easy as a git push.