Deploy Simple AWS Infrastructure Using Terraform

Building cloud infrastructure used to mean spending hours clicking through the AWS console and trying to remember which settings you used last time. I’ve been there – frantically taking screenshots of configurations and keeping messy notes just to recreate environments.

That’s where Terraform comes in. Instead of all that manual work, you write a few configuration files that describe exactly what you want your infrastructure to look like, and Terraform handles the rest.

Here’s what I am going to build:

  • A VPC
  • Public subnets for things that need internet access
  • Private subnets for your databases and sensitive stuff
  • All the networking pieces to make everything talk to each other
  • Security groups to keep the bad guys out
  • 3 EC2 instances

What you’ll need before we start:

  • An AWS account (the free tier works fine for this / elastic ip is around i think $0.005 per hr / if you have free aws credits, this won’t hurt)
  • Terraform installed on your computer
  • AWS CLI set up with your credentials
  • About an hour of your time

Let’s setup the environment first. Go to IAM > Create a User > Security Credentials Tab > Create Access Key. Create an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.

.env
AWS_ACCESS_KEY_ID=12345678
AWS_SECRET_ACCESS_KEY=12345678

Create a docker-compose file that creates a containerized Terraform environment with AWS CLI. The latest image of terraform will be used, make sure to mount the current directory to /workspace, and stdin_open and tty are set to true so that it will enable terminal access.

yml
services:
  terraform:
    image: hashicorp/terraform:latest
    working_dir: /workspace
    container_name: terraform-aws
    entrypoint: ["sh", "-c", "apk add --no-cache aws-cli && sleep infinity"]
    volumes:
      - .:/workspace
    stdin_open: true
    tty: true
    environment:
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - AWS_REGION=us-east-1

Run docker-compose up -d to start, then docker exec -it terraform-aws sh to access the container.

Bash
docker-compose up -d
docker exec -it terraform-aws sh

And this is the Terraform Project Structure:

Key Pairs

Key Pairs are cryptographic SSH keys used to securely authenticate and access EC2 instances without passwords. Key Pairs are like the master key and ID badges that let you securely access your employees or EC2 instances in your office building. Even if someone breaks into your building, they can’t access your employee’s work stations without the specific private badge reader. It’s important to note if you lose the private key file, you lose ssh access to your EC2 instance permanently or you can setup alternative access methods.

Generates a 4096-bit RSA private/public key pair in memory.

HCL
resource "tls_private_key" "private" {
    algorithm   =   "RSA"
    rsa_bits    =   4096
}

Registers the public key with AWS as an EC2 key pair named “terraform-key-pair”.

HCL
resource "aws_key_pair" "generated_key" {
    key_name    =   "terraform-key-pair"
    public_key  =   tls_private_key.private.public_key_openssh
}

Saves the private key as a .pem file to your local Terraform directory.

HCL
resource "local_file" "private_key" {
    content     =   tls_private_key.private.private_key_pem
    filename    =   "${path.root}/terraform-key-pair.pem"
}

Expose the return values of the above code to be used by other modules.

HCL
output "key_pair_name" {
    value = aws_key_pair.generated_key.key_name
}

output "tls_private_key_pem" {
    value = tls_private_key.private.private_key_pem
}

Create the keypair module.

HCL
module "keypair" {
    source          =   "../../modules/keypair"
}

You can see the full code here https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.

Networking

VPC

A VPC is a logically isolated section of a cloud provider’s network where you can launch and manage your cloud resources in a virtual network that you define and control. They are fundamental to cloud architecture because they can give you the network foundation needed to build a secure and scalable applications while maintaining control over your network environment. Like an office building floor, you can rent an entire floor of a sky scraper, you can decide how to divide it into rooms or subnets, who can access each rooms or security groups and weather some rooms have windows to the outside world or internet access. While others are interior offices or private subnets. It’s essentially cloud providers saying “here’s your own private piece of the internet where you can build whatever you need.

Create a VPC with 10.0.0.0/16 CIDR block.

HCL
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Name = "VPC"
  }
}

Expose the return values of the above code to be used by other modules.

HCL
output "vpc_id" {
  value = aws_vpc.main.id
}

output "vpc_cidr_block" {
  value = aws_vpc.main.cidr_block
}

Public Subnets

A Public Subnet has a route to an Internet Gateway. Think of it as a building’s main entrance that connects your floor directly to the street. Any server you put in a public subnet can receive traffic directly from the internet. Just like how people on the street can see and access those street-facing conference rooms. You have to note that just because a room has windows, doesn’t mean anyone can just walk in. You still have security or security groups, firewalls controlling exactly who can enter and what they can do.

Defines an input variable that accepts a list of subnet CIDR blocks, defaulting to one subnet (10.0.1.0/24).

HCL
variable "public_subnet_cidrs" {
    type = list(string)
    default = [ "10.0.1.0/24" ]
}

Defines an input variable that accepts a list of availability zones, defaulting to us-east-1a and us-east-1b.

HCL
variable "azs" {
    type = list(string)
    default = [ "us-east-1a", "us-east-1b" ]
}

Creates multiple public subnets in the VPC, one for each CIDR block in the variable, distributing them across the specified availability zones.

HCL
resource "aws_subnet" "public_subnets" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = element(var.public_subnet_cidrs, count.index)
  availability_zone = element(var.azs, count.index)

  tags = {
    Name = "Public Subnet ${count.index+1}"
  }
}

Expose the return values of the above code to be used by other modules.

HCL
output "public_subnets" {
  value = aws_subnet.public_subnets
}

output public_subnet_cidrs {
  value = var.public_subnet_cidrs
}

Private Subnets

A Private Subnet is like the interior offices and back rooms. They have no windows to the street and can’t be accessed directly from the outside world. It has no direct route to the internet gateway. There’s no street entrance to these rooms. These servers can’t receive traffic directly from the internet just like how people on the street cant walk directly in to your back offices. To be able for private subnet to access the outside world, it must go through NAT Gateway which we will not cover. Even if someone breaks through your perimeter security, your most critical systems: databases, internal applications, are in these window-less backrooms. Where they are much harder to reach and attack directly.

Defines an input variable for private subnet CIDR blocks, defaulting to two subnets (10.0.2.0/24 and 10.0.3.0/24).

HCL
variable "private_subnet_cidrs" {
  type = list(string)
  default = [ "10.0.2.0/24", "10.0.3.0/24" ]
}

Creates private subnets using the CIDR blocks and availability zones from variables, limited by whichever list is shorter.

HCL
resource "aws_subnet" "private_subnets" {
  count             = min(length(var.private_subnet_cidrs), length(var.azs))
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.azs[count.index]

  tags = {
    Name = "Private Subnet ${count.index + 1}"
  }
}

Expose the return values of the above code to be used by other modules.

HCL
output "private_subnets" {
  value = aws_subnet.private_subnets
}

You can see the full code here https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.

Internet Gateway

An Internet Gateway is like the main building entrance and lobby. Its a single point where your office floors connect to the outside world or the internet. Simply put, its an aws-managed component that connects your VPC to the internet. Without Internet Gateway, your VPC has no internet connectivity at all – completely isolated network.

Creates an internet gateway and attaches it to the VPC to enable internet access.

HCL
resource "aws_internet_gateway" "igw" {
  vpc_id  = aws_vpc.main.id

  tags = {
    Name = "Internet Gateway"
  }
}

Route Tables

Route Tables are network routing rules that determine where to send traffic based on destination IP addresses. When your web servers wants to download updates from the internet, it checks it’s route table. To reach 0.0.0.0/0, go to the Internet Gateway and sends the traffic there.

Creates a public route table in the VPC.

HCL
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "Public Route Table"
  }
}

Adds a route in the public route table that sends all traffic (0.0.0.0/0) to the internet gateway.

HCL
resource "aws_route" "public_internet_access" {
  route_table_id          = aws_route_table.public.id
  destination_cidr_block  = "0.0.0.0/0"
  gateway_id              = aws_internet_gateway.igw.id
}

Associates the first public subnet with the public route table.

HCL
resource "aws_route_table_association" "public_first_subnet" {
  subnet_id       = aws_subnet.public_subnets[0].id
  route_table_id  = aws_route_table.public.id
}

Creates a private route table in the VPC.

HCL
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "Private Route Table"
  }
}

Associates all private subnets with the private route table.

HCL
resource "aws_route_table_association" "private" {
  count           = length(var.private_subnet_cidrs)
  subnet_id       = element(aws_subnet.private_subnets[*].id, count.index)
  route_table_id  = aws_route_table.private.id
}

Create the networking module.

HCL
module "networking" {
    source          =   "../../modules/networking"
}

You can see the full code here https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.

Security Groups

Security Groups are virtual firewalls that control inbound and outbound traffic at the instance level like EC2 and RDS. Unlike the building’s main security that controls who can enter each room or area, security groups are like individual bodyguards that stick with specific employees wherever they go. For example, if someone approaches your employee, start a conversation which is called the inbound request and you bodyguard approves it, they automatically allow that person to respond back which is called the outbound response.

The Security Groups module will be accepting returned values from the Networking module which are the vpc_id and vpc_cidr_block. Create a variable for them.

HCL
variable "vpc_id" {
  type = string
}

variable "vpc_cidr_block" {
  type = string
}

Creates a public security group in the VPC.

HCL
resource "aws_security_group" "public" {
  name    = "public-sg"
  vpc_id  = var.vpc_id

  tags = {
    Name = "Public SG"
  }
}

Creates a private security group in the VPC.

HCL
resource "aws_security_group" "private" {
  name    = "private-sg"
  vpc_id  = var.vpc_id

  tags = {
    Name = "Private SG"
  }
}

Allows SSH outbound traffic from the public security group to the private security group.

HCL
resource "aws_vpc_security_group_egress_rule" "public_egress" {
  security_group_id             = aws_security_group.public.id
  from_port                     = 22
  to_port                       = 22
  ip_protocol                   = "tcp"
  referenced_security_group_id  = aws_security_group.private.id

  tags = {
    Name = "SSH Outgoing - Public SG -> Private SG"
  }
}

Allows SSH inbound traffic to the private security group from the public security group.

HCL
resource "aws_vpc_security_group_ingress_rule" "private_ingress" {
  security_group_id             = aws_security_group.private.id
  from_port                     = 22
  to_port                       = 22
  ip_protocol                   = "tcp"
  referenced_security_group_id  = aws_security_group.public.id

  tags = {
    Name = "SSH Incoming - Private SG <- Public SG"
  }
}

Allows SSH inbound traffic to the public security group from anywhere on the internet.

HCL
resource "aws_vpc_security_group_ingress_rule" "public_ssh_anywhere" {
  security_group_id = aws_security_group.public.id
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
  cidr_ipv4         = "0.0.0.0/0"

  tags = {
    Name = "Public SG SSH Anywhere"
  }
}

Create the security_groups module, passing the VPC ID and CIDR block from the networking module, and waits for networking to complete first.

HCL
module security_groups {
  source            =   "../../modules/security_groups"

  vpc_id            =   module.networking.vpc_id
  vpc_cidr_block    =   module.networking.vpc_cidr_block

  depends_on        =   [ module.networking ]
}

You can see the full code here https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.

EC2 Instances

Just like you hire different types of employees with different skills and assign them to different rooms in your office, EC2 instances are virtual computers that you hire from AWS that performs specific tasks. EC2 instances are your actual workforce. The virtual computers doing their real work in your cloud infrastructure just like employees doing real work in your office building.

Defines the private IP address for the public instance, defaulting to 10.0.1.10.

HCL
variable "public_instance_private_ip" {
  type = string
  default = "10.0.1.10"
}

Defines the private IP addresses for private instances, defaulting to 10.0.2.10 and 10.0.3.10.

HCL
variable "private_instance_private_ips" {
  type = list(string)
  default = [ "10.0.2.10", "10.0.3.10" ]
}

The compute module will be accepting returned values from the other modules. Create variables for them.

HCL
variable "vpc_id" {
  type = string
}

variable "private_subnets" {
  type = any
}

variable "public_subnets" {
  type = any
}

variable "public_security_group_id" {
  type = string
}

variable "private_security_group_id" {
  type = string
}

variable "tls_private_key_pem" {
  type = string
  sensitive = true
}

variable "key_pair_name" {
  type = string
}

variable "public_subnet_cidrs" {
  type = list(string)
}

Creates public EC2 instances in each public subnet with the specified AMI, instance type, and security group.

HCL
resource "aws_instance" "public" {
  count                   = length(var.public_subnet_cidrs)
  ami                     = "ami-084568db4383264d4"
  instance_type           = "t3.micro"
  key_name                = var.key_pair_name
  vpc_security_group_ids  = [var.public_security_group_id]
  subnet_id               = var.public_subnets[count.index].id 
  private_ip              = var.public_instance_private_ip

  tags = {
    Name = "Public Instance"
  }
}

Creates Elastic IP addresses for each public instance.

HCL
resource "aws_eip" "public_eip" {
  count   = length(var.public_subnet_cidrs)
  domain  = "vpc"
}

Associates each Elastic IP with its corresponding public instance.

HCL
resource "aws_eip_association" "public_eip_assoc" {
  count         = length(var.public_subnet_cidrs)
  instance_id   = aws_instance.public[count.index].id
  allocation_id = aws_eip.public_eip[count.index].id
}

Creates private EC2 instances in private subnets with custom root volumes and assigned private IPs.

HCL
resource "aws_instance" "private_instances" {
  for_each                = { for index, ip in var.private_instance_private_ips : index => ip }
  ami                     = "ami-084568db4383264d4"
  instance_type           = "t3.micro"
  key_name                = var.key_pair_name
  vpc_security_group_ids  = [var.private_security_group_id]
  subnet_id               = var.private_subnets[each.key].id
  private_ip              = var.private_instance_private_ips[each.key]

  root_block_device {
    volume_size           = 20
    volume_type           = "gp3"
    delete_on_termination = true
  }

  tags = {
    Name = "Private Instance ${each.key + 1}"
  }
}

Creates the compute module, passing networking details, security group IDs, and key pair information from other modules, and waits for all dependencies to complete first.

HCL
module "compute" {
  source                    =   "../../modules/compute"

  vpc_id                    =   module.networking.vpc_id
  public_subnet_cidrs       =   module.networking.public_subnet_cidrs
  private_subnets           =   module.networking.private_subnets
  public_subnets            =   module.networking.public_subnets
  public_security_group_id  =   module.security_groups.public_security_group_id
  private_security_group_id =   module.security_groups.private_security_group_id
  key_pair_name             =   module.keypair.key_pair_name
  tls_private_key_pem       =   module.keypair.tls_private_key_pem

  depends_on                =   [ module.keypair, module.networking, module.security_groups ]
}

Policies

In aws, create a terraform-user and add these policies:

JSON
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Effect": "Allow",
			"Action": [
				"ec2:DescribeVpcAttribute",
				"ec2:DescribeInstanceTypes",
				"ec2:DescribeAddressesAttribute",
				"ec2:CreateVpc",
				"ec2:DeleteVpc",
				"ec2:DescribeVpcs",
				"ec2:ModifyVpcAttribute",
				"ec2:CreateSubnet",
				"ec2:DeleteSubnet",
				"ec2:DescribeSubnets",
				"ec2:CreateInternetGateway",
				"ec2:DeleteInternetGateway",
				"ec2:DescribeInternetGateways",
				"ec2:AttachInternetGateway",
				"ec2:DetachInternetGateway",
				"ec2:CreateRouteTable",
				"ec2:DeleteRouteTable",
				"ec2:DescribeRouteTables",
				"ec2:AssociateRouteTable",
				"ec2:DisassociateRouteTable",
				"ec2:CreateRoute",
				"ec2:DeleteRoute",
				"ec2:CreateSecurityGroup",
				"ec2:DeleteSecurityGroup",
				"ec2:DescribeSecurityGroups",
				"ec2:DescribeSecurityGroupRules",
				"ec2:AuthorizeSecurityGroupIngress",
				"ec2:AuthorizeSecurityGroupEgress",
				"ec2:RevokeSecurityGroupIngress",
				"ec2:RevokeSecurityGroupEgress",
				"ec2:CreateKeyPair",
				"ec2:DeleteKeyPair",
				"ec2:DescribeKeyPairs",
				"ec2:ImportKeyPair",
				"ec2:RunInstances",
				"ec2:TerminateInstances",
				"ec2:DescribeInstances",
				"ec2:DescribeInstanceAttribute",
				"ec2:AllocateAddress",
				"ec2:ReleaseAddress",
				"ec2:DescribeAddresses",
				"ec2:AssociateAddress",
				"ec2:DisassociateAddress",
				"ec2:CreateTags",
				"ec2:DescribeTags",
				"ec2:DescribeAvailabilityZones",
				"ec2:DescribeImages",
				"ec2:DescribeVolumes",
				"ec2:DescribeInstanceCreditSpecifications",
				"ec2:DescribeNetworkInterfaces"
			],
			"Resource": "*"
		}
	]
}

Run the following to see the changes.

Bash
terraform init
terraform plan -out=aws.tfplan
terraform apply aws.tfplan

If you want to destroy everything, run

Bash
terraform destroy --auto-approve

You can see this on Github https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.

Leave a Reply