A video for this project is coming soon (like this). The source code is here.

What I Built

I deployed a Tetris app on AWS with Terraform. I wanted the setup to be scalable and secure. It uses ALB, ASG, CloudFront, Route53, and a VPC, all built with Terraform best practices. It also has security groups and NAT, which handle the network side.

What You Need

Downloads

AWS CLI - Installed on your local machine.

Terraform - Installed on your local machine.

AWS Account Setup

AWS IAM - I created an IAM user with Administrator Access and made an access key and secret key.

AWS CLI - I configured the AWS CLI with that access key and secret key.

Your region is dependent on your AMI. If you change AMI, change region to match it

Your region is dependent on your AMI. If you change AMI, change region to match it

Diagram

I drew a diagram of the infrastructure before building it.

In this VPC you can see a public subnet in both AZ-1 and AZ-2 that contain NAT Gateways, and private subnets that contain the EC2 instances.

In this VPC you can see a public subnet in both AZ-1 and AZ-2 that contain NAT Gateways, and private subnets that contain the EC2 instances.

  1. Route 53: DNS

    • What it does: Turns domain names into IP addresses.
    • Why it matters: People reach the app through a domain name.
  2. Amazon Certificate Manager: Secure Connections

    • What it does: Manages SSL/TLS certificates.
    • Why it matters: Traffic is encrypted. You get the lock icon in the browser.
  3. CloudFront: Content Delivery

    • What it does: AWS’s CDN (content delivery network). It caches content close to users.
    • Why it matters: Lower latency means a faster app.
  4. Elastic Load Balancing: Spreading Traffic

    • What it does: Spreads traffic across the EC2 instances in different AZs.
    • Why it matters: The app stays up if one instance fails.
  5. VPC: Network Isolation

    • Layout: Public and private subnets in each AZ.
    • Public Subnet: Holds the NAT Gateways for internet access.
    • Private Subnet: Holds the EC2 instances that run the app.
  6. Auto Scaling: Matching Demand

    • What it does: Adds or removes EC2 instances based on demand.
    • Why it matters: Handles load and keeps costs down.
  7. Also Used: Database and Storage (Not Shown)

    • DynamoDB: Terraform uses it to lock the state file during changes. That stops two changes from happening at once.
    • S3: Stores the Terraform state remotely. That helps with teamwork and disaster recovery.
  8. CloudWatch: Monitoring

    • What it does: Watches health and performance. It fires alarms and runs actions.
    • Why it matters: Makes the app easier to manage and drives auto scaling.

Terraform Layout

The project has two main folders: root and modules.

  • Root: The main config files for the infrastructure.
  • Modules: A separate module for each service (ALB, ASG, CloudFront, etc.).

Root Folder

Holds the main Terraform files. This is where the modules get wired together, variables get passed between them, and the backend and provider are set up.

  • main.tf: Main config file.
  • variables.tf: Variable declarations.
  • terraform.tfvars: Variable values (should be gitignored).
  • providers.tf: Provider config (AWS).
  • backend.tf: Backend config (S3 and DynamoDB).

Modules Folder

Each service gets its own module. This is where the resources for each service are defined.

  • alb: Application Load Balancer.
  • asg: Auto Scaling Group.
  • cloudfront: CloudFront.
  • key: Key Pair.
  • nat: NAT Gateway.
  • route53: Route 53.
  • security-group: Security Group.
  • vpc: VPC.

Each module folder usually has three files:

  • main.tf: Main config file.
  • variables.tf: Variable declarations.
  • output.tf: Output declarations.
.
├── modules
│   ├── alb
│   │   ├── main.tf
│   │   ├── output.tf
│   │   └── variables.tf
│   ├── asg
│   │   ├── config.sh
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── cloudfront
│   │   ├── main.tf
│   │   ├── output.tf
│   │   └── variables.tf
│   ├── key
│   │   ├── client_key
│   │   ├── client_key.pub
│   │   ├── main.tf
│   │   └── output.tf
│   ├── nat
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── route53
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── security-group
│   │   ├── main.tf
│   │   ├── output.tf
│   │   └── variables.tf
│   └── vpc
│       ├── main.tf
│       ├── output.tf
│       └── variables.tf
├── root
│   ├── backend.tf
│   ├── main.tf
│   ├── providers.tf
│   ├── terraform.tfvars
│   └── variables.tf
└── terraform.tfstate

# terraform.tfstate is generated after running terraform apply.
# It contains the state of the infrastructure and is stored in S3.
# It should be gitignored.

Modules

VPC

Overview

The VPC is the foundation for everything else. It’s an isolated network inside AWS that I define myself. This module sets up the network: the VPC, subnets, route tables, and NAT Gateways.

Components

  • Virtual Private Cloud (VPC): A virtual network that I define. It’s split into subnets.

  • Internet Gateway (IGW): Connects the VPC to the internet. Resources in the public subnets can reach the internet and be reached from it. The IGW is attached to the VPC.

  • Subnets: Sections of the VPC that divide up the network.

    • Public Subnets: Reachable from the internet. They hold the NAT Gateways that let EC2 instances in the private subnets reach the internet. There’s one in each AZ.
    • Private Subnets: Not reachable from the internet. Each holds an EC2 instance running the web app. There’s one in each AZ.
  • Route Tables: Hold rules, called routes, that decide where traffic goes. The public route table has a default route to the IGW for outbound traffic. There’s one public route table for each public subnet.

  • Route Table Association: Links the public subnets to the public route table. Every subnet in a VPC must have a route table. One route table can serve many subnets, but each subnet can only have one route table. There’s one association for each public subnet.

  • Availability Zones (AZs): Separate locations inside a region, isolated from failures in the others. I used two, AZ-1 and AZ-2.

Terraform Code

A quick note first. A lot of Terraform code is boilerplate. You can reuse it across projects and just copy and tweak it. The official Terraform docs have plenty of examples (https://registry.terraform.io/).

variables.tf

# Define variables
variable region {}
variable project_name {}
variable vpc_cidr {}
variable pub_sub_1a_cidr {}
variable pub_sub_2b_cidr {}
variable pri_sub_3a_cidr {}
variable pri_sub_4b_cidr {}

output.tf

# Define outputs
output "region" {
  value = var.region
}

output "project_name" {
  value = var.project_name
}

output "vpc_id" {
  value = aws_vpc.vpc.id
}

output "pub_sub_1a_id" {
  value = aws_subnet.pub_sub_1a.id
}
output "pub_sub_2b_id" {
  value = aws_subnet.pub_sub_2b.id
}
output "pri_sub_3a_id" {
  value = aws_subnet.pri_sub_3a.id
}

output "pri_sub_4b_id" {
  value = aws_subnet.pri_sub_4b.id
}


output "igw_id" {
    value = aws_internet_gateway.internet_gateway
}

main.tf

# Create a Virtual Private Cloud (VPC) resource
resource "aws_vpc" "vpc" {
  cidr_block              = var.vpc_cidr # Set CIDR block from variables.tf
  instance_tenancy        = "default"    
  enable_dns_hostnames    = true     # Enable DNS hostnames+support for easier access
  enable_dns_support      = true       

  # Set tags for identification
  tags = {
    Name = "${var.project_name}-vpc"
  }
}

# Create IGW (Internet Gateway) and associate it with the VPC for internet access
resource "aws_internet_gateway" "internet_gateway" {
  vpc_id = aws_vpc.vpc.id # Associate it

  tags = {
    Name = "${var.project_name}-igw"
  }
}

# Use data source to get AZs for the region
data "aws_availability_zones" "available_zones" {}

# Create a public subnet in AZ-1
resource "aws_subnet" "pub_sub_1a" {
  vpc_id                  = aws_vpc.vpc.id 
  cidr_block              = var.pub_sub_1a_cidr 
  availability_zone       = data.aws_availability_zones.available_zones.names[0]
  map_public_ip_on_launch = true # Auto-assign public IP when it launches

  tags = {
    Name = "pub_sub_1a"
  }
}

# Create a second public subnet in AZ-2
resource "aws_subnet" "pub_sub_2b" {
  vpc_id                  = aws_vpc.vpc.id
  cidr_block              = var.pub_sub_2b_cidr
  availability_zone       = data.aws_availability_zones.available_zones.names[1]
  map_public_ip_on_launch = true

  tags = {
    Name = "pub_sub_2b"
  }
}

# Create a public route table
resource "aws_route_table" "public_route_table" {
  vpc_id = aws_vpc.vpc.id

  # IGW default route for outbound traffic
  route {
    cidr_block = "0.0.0.0/0" # all IP's
    gateway_id = aws_internet_gateway.internet_gateway.id
  }

  
  tags = {
    Name = "Public-rt"
  }
}

# Associate pub_sub_1a with the public route table
resource "aws_route_table_association" "pub-sub-1a_route_table_association" {
  subnet_id      = aws_subnet.pub_sub_1a.id
  route_table_id = aws_route_table.public_route_table.id
}

#  Associate pub_sub_2b with the public route table
resource "aws_route_table_association" "pub-sub-2-b_route_table_association" {
  subnet_id      = aws_subnet.pub_sub_2b.id
  route_table_id = aws_route_table.public_route_table.id
}

# Make a private subnet in AZ-1
resource "aws_subnet" "pri_sub_3a" {
  vpc_id                  = aws_vpc.vpc.id
  cidr_block              = var.pri_sub_3a_cidr
  availability_zone       = data.aws_availability_zones.available_zones.names[0]
  map_public_ip_on_launch = false # No public IP (private)

  tags = {
    Name = "pri-sub-3a"
  }
}

# Define a second private subnet in AZ-2
resource "aws_subnet" "pri_sub_4b" {
  vpc_id                  = aws_vpc.vpc.id
  cidr_block              = var.pri_sub_4b_cidr
  availability_zone       = data.aws_availability_zones.available_zones.names[1]
  map_public_ip_on_launch = false

  tags = {
    Name = "pri-sub-4b"
  }
}

Security Group:

Overview

A security group is a virtual firewall for inbound and outbound traffic in the VPC. I made two, one for the ALB and one for the EC2 instances.

Components

  • Security Group for the ALB (Application Load Balancer):
    • Allows inbound HTTP and HTTPS from any IP.
    • Allows all outbound traffic.
  • Security Group for the Client:
    • Allows inbound HTTP only from the ALB security group.
    • Allows all outbound traffic.

Terraform Code

variables.tf

# Define variables
variable vpc_id {}

output.tf

# Define outputs

output "alb_sg_id" {
  value = aws_security_group.alb_sg.id
}

output "client_sg_id" {
  value = aws_security_group.client_sg.id
}

main.tf

# create a security group for ALB
resource "aws_security_group" "alb_sg" {
  name        = "alb security group" 
  description = "enable http/https access on port 80/443" 
  vpc_id      = var.vpc_id 

  # Ingress rule to allow HTTP (port 80) from anywhere
  ingress {
    description = "http access"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] # allows access from anywhere
  }

  # Ingress rule to allow HTTPS (port 443) from anywhere
  ingress {
    description = "https access"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] 
  }

  # Egress rule to allow all traffic
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = -1 # -1 means all protocols
    cidr_blocks = ["0.0.0.0/0"] 
  }

  tags = {
    Name = "alb_sg"
  }
}

# create security group for client instances
resource "aws_security_group" "client_sg" {
  name        = "client_sg" 
  description = "enable http/https access on port 80 for elb sg" 
  vpc_id      = var.vpc_id 

  # Ingress rule to allow HTTP (port 80) from alb_sg
  ingress {
    description     = "http access"
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb_sg.id] # allows traffic solely from alb_sg
  }

  # Egress rule to allow all traffic
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = -1 
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "Client_sg"
  }
}

Key Pair:

Overview

Normally a key pair lets you SSH into EC2 instances. Mine are in a private subnet, so I used AWS Systems Manager (SSM) for access instead. The key pair still lets the ASG launch and scale the instances.

Components

  • Key Pair: A public and private key used to connect to and authenticate with an EC2 instance. The public key lives in the client_key.pub file. The ASG uses it to launch and scale instances.

Run ssh-keygen in the key folder to make the key pair

Terraform Code

output.tf

# Define outputs
output "key_name" {
  value = aws_key_pair.client_key.key_name
}

main.tf

# Create AWS key pair resource for the client
resource "aws_key_pair" "client_key" {
    key_name   = "client_key"                 
    public_key = file("../modules/key/client_key.pub")  # Provide path for .pub file
}

NAT Gateway:

Overview

The NAT Gateway lets instances in the private subnets reach the internet without being exposed to it. That’s a big deal for security.

Components

  • Elastic IPs (EIPs): A static IP you attach to a NAT Gateway so its address never changes. There’s one for each NAT Gateway.

  • NAT Gateway: Lets private instances reach the internet without being reachable from it. There’s one in each public subnet.

  • Private Route Tables: Send outbound traffic to the NAT Gateway and block direct inbound traffic. There’s one for each private subnet.

  • Route Table Association: Links the private subnets to the private route tables. Every subnet must have a route table. One route table can serve many subnets, but each subnet can only have one.

Terraform Code

variables.tf


variable pub_sub_1a_id {}
variable pub_sub_2b_id {}
variable pri_sub_3a_id {}
variable pri_sub_4b_id {}
variable igw_id {}
variable vpc_id {}

main.tf

# allocate an EIP for NAT Gateway in pub-sub-1-a
resource "aws_eip" "eip-nat-a" {
  vpc = true # since EIP is for use with a VPC

  tags = {
    Name = "eip-nat-a" 
  }
}

# allocate an EIP for NAT Gateway in pub-sub-2-b
resource "aws_eip" "eip-nat-b" {
  vpc = true 

  tags = {
    Name = "eip-nat-b" 
  }
}

# make a NAT Gateway in pub-sub-1a
resource "aws_nat_gateway" "nat-a" {
  allocation_id = aws_eip.eip-nat-a.id 
  subnet_id     = var.pub_sub_1a_id    

  tags = {
    Name = "nat-a" 
  }

  # depends on IGW to for proper ordering
  depends_on = [var.igw_id]
}

# make a NAT Gateway in pub-sub-2b
resource "aws_nat_gateway" "nat-b" {
  allocation_id = aws_eip.eip-nat-b.id 
  subnet_id     = var.pub_sub_2b_id    

  tags = {
    Name = "nat-b" 
  }

  
  depends_on = [var.igw_id]
}

# Create pri-rt-a with default route aws_nat_gateway.nat-a.id
resource "aws_route_table" "pri-rt-a" {
  vpc_id = var.vpc_id 

  # route outbound traffic to aws_nat_gateway.nat-a.id
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat-a.id
  }

  tags = {
    Name = "Pri-rt-a"
  }
}

# Associate pri-sub-3-a with pri-rt-a
resource "aws_route_table_association" "pri-sub-3a-with-Pri-rt-a" {
  subnet_id      = var.pri_sub_3a_id   
  route_table_id = aws_route_table.pri-rt-a.id
}

# Associate pri-sub-4b with pri-rt-b
resource "aws_route_table_association" "pri-sub-4b-with-Pri-rt-b" {
  subnet_id      = var.pri_sub_4b_id   
  route_table_id = aws_route_table.pri-rt-b.id 
}

# Create pri-rt-b with default route aws_nat_gateway.nat-b.id
resource "aws_route_table" "pri-rt-b" {
  vpc_id = var.vpc_id 

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat-b.id
  }

  tags = {
    Name = "pri-rt-b" 
  }
}

ALB (Application Load Balancer):

Overview

The ALB spreads incoming traffic across targets like EC2 instances in different AZs. That keeps the app up if something fails.

Components

  • Application Load Balancer (ALB): Routes HTTP/S traffic to targets inside the VPC. It handles changing traffic in one AZ or across several.

  • ALB Listener: Listens for requests on port 80 here and forwards them to a target group, which sends them to the EC2 instances.

  • ALB Target Group: Sends requests to the EC2 instances. It can send to one target or many across different AZs.

Terraform Code

variables.tf

variable project_name {}
variable alb_sg_id {}
variable pub_sub_1a_id {}
variable pub_sub_2b_id {}
variable vpc_id {}

output.tf

output "tg_arn" {
  value = aws_lb_target_group.alb_target_group.arn
}

output "alb_dns_name" {
  value = aws_lb.application_load_balancer.dns_name
}

main.tf

# Create the alb
resource "aws_lb" "application_load_balancer" {
  name               = "${var.project_name}-alb" 
  internal           = false                     # since ALB is internet-facing
  load_balancer_type = "application"            # application type since HHTP/S traffic
  security_groups    = [var.alb_sg_id]          # associate with alb_sg
  subnets            = [var.pub_sub_1a_id, var.pub_sub_2b_id] # associate with pub-sub-1a and pub-sub-2b

  enable_deletion_protection = false 

  tags = {
    Name = "${var.project_name}-alb" 
  }
}

# Create target group
resource "aws_lb_target_group" "alb_target_group" {
  name        = "${var.project_name}-tg" 
  target_type = "instance"              # since targets are EC2 instances
  port        = 80                      # port for http traffic
  protocol    = "HTTP"                  
  vpc_id      = var.vpc_id             # associate with vpc

  # health check settings 
  health_check {
    enabled             = true       
    interval            = 300        # time between checks (seconds) 
    path                = "/"        
    timeout             = 60         # time to wait for a response (seconds)
    matcher             = 200        # expected HTTP code for healthy target
    healthy_threshold   = 2          # 2 consecutive successful health checks = healthy
    unhealthy_threshold = 5          # 5 consecutive failed health checks = unhealthy
  }

  lifecycle {
    create_before_destroy = true # create new target group before destroying old one
  }
}

# Create listener 
resource "aws_lb_listener" "alb_http_listener" {
  load_balancer_arn = aws_lb.application_load_balancer.arn # associate with ALB
  port              = 80                                  # port for HTTP traffic
  protocol          = "HTTP"                              

  # forward requests to target group
  default_action {
    type             = "forward"                        
    target_group_arn = aws_lb_target_group.alb_target_group.arn # associate with target group
  }
}

ASG (Auto Scaling Group):

Overview

The ASG adds or removes EC2 instances to match the load. I set it to a minimum of 2 and a maximum of 6.

Components

  • Launch Template: Defines how each EC2 instance is built. It sets the AMI ID, instance type, key pair, security group, and user data script. The user data script installs Apache and deploys the web app.

  • IAM Instance Profile: Sets the permissions for the EC2 instances, including the IAM role and the SSM role.

  • Auto Scaling Group (ASG): Adds or removes instances to match the load. Minimum of 2, maximum of 6.

  • Auto Scaling Policies and Alarms: Two policies and alarms scale up and down based on CPU. It scales up at 70% CPU or higher and scales down at 5% or lower.

  • IAM Role: Sets the permissions for the EC2 instances, including SSM. SSM is how I get into the instances.

  • IAM Role Policy Attachment: Attaches the SSM policy to the IAM role.

Terraform Code

variables.tf

# Define variables
variable "project_name"{}
variable "ami" {
    default = "ami-053b0d53c279acc90" # Ubuntu 20.04 AMI in us-east-1
}
variable "cpu" {
    default = "t2.micro" # t2.micro instance type
}
variable "key_name" {}
variable "client_sg_id" {}
variable "max_size" {
    default = 6
}
variable "min_size" {
    default = 2
}
variable "desired_cap" {
    default = 3
}
variable "asg_health_check_type" {
    default = "ELB" 
}
variable "pri_sub_3a_id" {}
variable "pri_sub_4b_id" {}
variable "tg_arn" {} 

main.tf


# Create a launch template for EC2 instances
resource "aws_launch_template" "lt_name" {
  name          = "${var.project_name}-tpl" me
  image_id      = var.ami                  # AMI ID for the EC2 instances
  instance_type = var.cpu                 # Instance type for the EC2 instances
  key_name      = var.key_name             # key pair name for the EC2 instances
  user_data     = filebase64("../modules/asg/config.sh") # User data script for the EC2 instances

  # Specify the IAM Instance Profile for the EC2 instances
  iam_instance_profile {
    name = aws_iam_instance_profile.ssm_instance_profile.name
  }

  # SG IDs to associate with the EC2 instances
  vpc_security_group_ids = [var.client_sg_id]

  
  tags = {
    Name = "${var.project_name}-tpl"
  }
}

# Create the IAM Instance Profile for EC2 instances
resource "aws_iam_instance_profile" "ssm_instance_profile" {
  name = "${var.project_name}-instance-profile" 
  role = aws_iam_role.ssm_role.name             # associate SSM role with instance profile
}

# Create the Auto Scaling Group (ASG)
resource "aws_autoscaling_group" "asg_name" {
  name                      = "${var.project_name}-asg" 
  max_size                  = var.max_size              #6 instances
  min_size                  = var.min_size             #2 instances 
  desired_capacity          = var.desired_cap          #3 instances
  health_check_grace_period = 300                # Wait 300 seconds before checking health
  health_check_type         = var.asg_health_check_type # ELB health check
  vpc_zone_identifier       = [var.pri_sub_3a_id, var.pri_sub_4b_id] # Subnets for ASG instances
  target_group_arns         = [var.tg_arn]              # Target group for ASG instances

  # enable metrics collection
  enabled_metrics = [
    "GroupMinSize",
    "GroupMaxSize",
    "GroupDesiredCapacity",
    "GroupInServiceInstances",
    "GroupTotalInstances"
  ]

  metrics_granularity = "1Minute" 

  # Reference the launch template
  launch_template {
    id      = aws_launch_template.lt_name.id
    version = aws_launch_template.lt_name.latest_version 
  }
}

# Auto Scaling policy for scaling up
resource "aws_autoscaling_policy" "scale_up" {
  name                   = "${var.project_name}-asg-scale-up" 
  autoscaling_group_name = aws_autoscaling_group.asg_name.name
  adjustment_type        = "ChangeInCapacity" 
  scaling_adjustment     = "1"                # Increase by 1
  cooldown               = "300"              
  policy_type            = "SimpleScaling"
}

# CloudWatch metric alarm for scaling up
resource "aws_cloudwatch_metric_alarm" "scale_up_alarm" {
  alarm_name          = "${var.project_name}-asg-scale-up-alarm"
  alarm_description   = "asg-scale-up-cpu-alarm"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = "2"
  metric_name         = "CPUUtilization"
  namespace           = "AWS/EC2"
  period              = "120" 
  statistic           = "Average"
  threshold           = "70" # Triggers at 70% CPU utilization
  dimensions = {
    "AutoScalingGroupName" = aws_autoscaling_group.asg_name.name
  }
  actions_enabled = true
  alarm_actions   = [aws_autoscaling_policy.scale_up.arn]
}

# Auto Scaling policy for scaling down
resource "aws_autoscaling_policy" "scale_down" {
  name                   = "${var.project_name}-asg-scale-down"
  autoscaling_group_name = aws_autoscaling_group.asg_name.name
  adjustment_type        = "ChangeInCapacity"
  scaling_adjustment     = "-1" # Decrease instances by 1
  cooldown               = "300"
  policy_type            = "SimpleScaling"
}

# CloudWatch metric alarm for scaling down
resource "aws_cloudwatch_metric_alarm" "scale_down_alarm" {
  alarm_name          = "${var.project_name}-asg-scale-down-alarm"
  alarm_description   = "asg-scale-down-cpu-alarm"
  comparison_operator = "LessThanOrEqualToThreshold"
  evaluation_periods  = "2"
  metric_name         = "CPUUtilization"
  namespace           = "AWS/EC2"
  period              = "120"
  statistic           = "Average"
  threshold           = "5" # Trigger at 5% CPU utilization
  dimensions = {
    "AutoScalingGroupName" = aws_autoscaling_group.asg_name.name
  }
  actions_enabled = true
  alarm_actions   = [aws_autoscaling_policy.scale_down.arn]
}

# IAM role for EC2 instances
resource "aws_iam_role" "ssm_role" {
  name = "${var.project_name}-ssm-role" 

  # Use an IAM policy document to define the permissions for the IAM role
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Action = "sts:AssumeRole",
        Effect = "Allow",
        Principal = {
          Service = "ec2.amazonaws.com"
        },
      },
    ]
  })
}

# Attach the policy to the IAM role
resource "aws_iam_role_policy_attachment" "ssm_policy_attachment" {
  role       = aws_iam_role.ssm_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" # Standard SSM policy
}

config.sh

#!/bin/bash

#!/bin/bash is called a shebang and it tells the system which interpreter to use. 
# In this case, it is using Bash shell (/bin/bash).


# URL of the Tetris game from GitHub
URL='https://github.com/davidsvida/tetrisjs/archive/refs/heads/main.zip'
# Name of the extracted directory
ART_NAME='tetrisjs-main'
# Temporary directory for downloading and extracting the application
TEMPDIR="/tmp/webfiles"
# Packages to install (Apache web server, wget for downloading files, unzip for extraction)
PACKAGE="apache2 wget unzip"
# Service name for Apache
SVC="apache2"

echo "Running setup on Ubuntu"
echo "Installing packages..."
sudo apt update  # Update the package list
sudo apt install -y $PACKAGE  # Install packages


echo "Starting and enabling HTTPD service..."
sudo systemctl start $SVC   # Start Apache 
sudo systemctl enable $SVC  # Enable Apache to start on boot


echo "Starting artifact deployment..."
mkdir -p $TEMPDIR  # Create the temporary directory
cd $TEMPDIR       # Change to the temporary directory


wget -q $URL -O "$ART_NAME.zip"  # Download the zip file
unzip -q "$ART_NAME.zip"         # Unzip the downloaded file

# Clean /var/www/html (web root) and copy the new files
echo "Deploying new files..."
sudo rm -rf /var/www/html/*                     # Remove all in the web root
sudo cp -r $ART_NAME/* /var/www/html/           # Copy the new files to the web root
sudo chown -R www-data:www-data /var/www/html/  # Change ownership to the web server user
sudo find /var/www/html -type d -exec chmod 755 {} \;  # Set appropriate DIRECTORY permissions
sudo find /var/www/html -type f -exec chmod 644 {} \;  # Set appropriate FILE permissions 

echo "Restarting HTTPD service..."
sudo systemctl restart $SVC  # Restart the Apache service to apply changes

echo "Removing temporary files..."
rm -rf $TEMPDIR  # Remove the temporary directory
echo "Setup complete."

CloudFront:

Overview

CloudFront is a CDN that serves content from Edge Locations close to users. That makes delivery faster. Here I use it to send traffic to the ALB.

Components

  • AWS ACM Certificate (ACM): A digital certificate. I have one for davidinsider.com.

  • CloudFront Distribution: A set of Edge Locations that deliver content to users. I have one for davidinsider.com.

Terraform Code

variables.tf

variable "certificate_domain_name"{}
variable "alb_domain_name" {}
variable "additional_domain_name" {}
variable "project_name" {}

output.tf


output "cloudfront_domain_name" {
  value = aws_cloudfront_distribution.my_distribution.domain_name
}

output "cloudfront_id" {
  value = aws_cloudfront_distribution.my_distribution.id
}

output "cloudfront_arn" {
  value = aws_cloudfront_distribution.my_distribution.arn
}


output "cloudfront_status" {
  value = aws_cloudfront_distribution.my_distribution.status
}

output "cloudfront_hosted_zone_id" {
  value = aws_cloudfront_distribution.my_distribution.hosted_zone_id
}

main.tf

# data block that retrieves information about the ACM certificate
data "aws_acm_certificate" "issued" {
  domain   = var.certificate_domain_name  
  statuses = ["ISSUED"]               
}

# Create a CloudFront distribution resource
resource "aws_cloudfront_distribution" "my_distribution" {
  enabled             = true                                 
  aliases             = [var.additional_domain_name]          

  # Define the origin of the content (an ALB in this case)
  origin {
    domain_name = var.alb_domain_name        
    origin_id   = var.alb_domain_name        

    # Configuration for the origin
    custom_origin_config {
      http_port              = 80             # HTTP 
      https_port             = 443            # HTTPS 
      origin_protocol_policy = "http-only"    # Use only HTTP
      origin_ssl_protocols   = ["TLSv1.2"]    # TLS protocols used for HTTPS
    }
  }

  # Default cache behavior for the distribution
  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods         = ["GET", "HEAD", "OPTIONS"]
    target_origin_id       = var.alb_domain_name  
    viewer_protocol_policy = "redirect-to-https" 
    forwarded_values {
      headers      = []        # Forward no headers to the origin
      query_string = true       # Forward query strings
      cookies {
        forward = "all"        # Forward cookies 
      }
    }
  }

  # Restrictions for the distribution, none in this case
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  
  tags = {
    Name = var.project_name
  }

  # Configuration for the SSL/TLS certificate and viewer protocol
  viewer_certificate {
    acm_certificate_arn      = data.aws_acm_certificate.issued.arn  
    ssl_support_method       = "sni-only"                         
    minimum_protocol_version = "TLSv1.2_2018"                       
  }
}

Route53:

Overview

Route53 is AWS’s DNS (Domain Name System) service. It turns domain names into IP addresses. I use it to send traffic to the ALB.

Components

  • Route53 Hosted Zone: A container for DNS records. I have one for davidinsider.com

  • Route53 Record Resource: Creates a DNS record in the hosted zone. I use it to point a record at the CloudFront distribution.

Terraform Code

variables.tf


variable hosted_zone_name{
    default = "davidinsider.com"
}

variable cloudfront_hosted_zone_id {}

variable cloudfront_domain_name {}

main.tf

# data block that retrieves information about davidinsider.com hosted zone
data "aws_route53_zone" "public-zone" {
  name         = var.hosted_zone_name   
  private_zone = false                  
}

# resource block to create a DNS record in davidinsider.com hosted zone
resource "aws_route53_record" "cloudfront_record" {
  zone_id = data.aws_route53_zone.public-zone.zone_id  
  name    = "terraform.${data.aws_route53_zone.public-zone.name}"  # DNS record name (terraform.davidinsider.com)
  type    = "A"  # alias record

  # alias block for routing to cloudfront distribution
  alias {
    name                   = var.cloudfront_domain_name      
    zone_id                = var.cloudfront_hosted_zone_id  
    evaluate_target_health = false  
  }
}

Root:

backend.tf

terraform {
  backend "s3" {
    bucket = "tetrisstatestorage"
    key    = "tetrisdemo.tfstate"
    region = "us-east-1"
    dynamodb_table = "newtfTable"
  }
}

main.tf

#creating a VPC
module "vpc" {
  source = "../modules/vpc"
  region = var.region
  project_name = var.project_name
  vpc_cidr = var.vpc_cidr
  pub_sub_1a_cidr = var.pub_sub_1a_cidr
  pub_sub_2b_cidr = var.pub_sub_2b_cidr
  pri_sub_3a_cidr = var.pri_sub_3a_cidr
  pri_sub_4b_cidr = var.pri_sub_4b_cidr
}

#creating NAT gateways
module "nat" {
  source = "../modules/nat"
  pub_sub_1a_id = module.vpc.pub_sub_1a_id
  igw_id = module.vpc.igw_id
  pub_sub_2b_id = module.vpc.pub_sub_2b_id
  vpc_id = module.vpc.vpc_id
  pri_sub_3a_id = module.vpc.pri_sub_3a_id
  pri_sub_4b_id = module.vpc.pri_sub_4b_id
}

#creating Security Groups (SG's)
module "security-group" {
  source = "../modules/security-group"
  vpc_id = module.vpc.vpc_id
}

#creating SSH Key for instances
module "key" {
  source = "../modules/key"
}

#creating the ALB
module "alb" {
  source = "../modules/alb"
  project_name = module.vpc.project_name
  alb_sg_id = module.security-group.alb_sg_id
  pub_sub_1a_id = module.vpc.pub_sub_1a_id
  pub_sub_2b_id = module.vpc.pub_sub_2b_id
  vpc_id = module.vpc.vpc_id
}

#creating the ASG
module "asg" {
  source = "../modules/asg"
  project_name = module.vpc.project_name
  key_name = module.key.key_name
  client_sg_id = module.security-group.client_sg_id
  pri_sub_3a_id = module.vpc.pri_sub_3a_id
  pri_sub_4b_id = module.vpc.pri_sub_4b_id
  tg_arn = module.alb.tg_arn
}

#creating a CloudFront distribution
module "cloudfront" {
  source = "../modules/cloudfront"
  certificate_domain_name = var.certificate_domain_name
  alb_domain_name = module.alb.alb_dns_name
  additional_domain_name = var.additional_domain_name
  project_name = module.vpc.project_name
}


# creating a Route53 hosted zone
module "route53" {
  source = "../modules/route53"
  cloudfront_domain_name = module.cloudfront.cloudfront_domain_name
  cloudfront_hosted_zone_id = module.cloudfront.cloudfront_hosted_zone_id
}

providers.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "4.67.0"
    }
  }
}

provider "aws" {
  region = var.region 
}

terraform.tfvars

region = "us-east-1"
project_name = "DavidTF"
# The '10.0.0.0/16' specifies a range from 10.0.0.0 to 10.0.255.255.
# This allows for 2^16 IP addresses providing a large address space for the VPC.
vpc_cidr = "10.0.0.0/16"
# '10.0.1.0/24' specifies a range from 10.0.1.0 to 10.0.1.255.
# This allows for 2^8 IP addresses for smaller/isolated networks within the VPC.
pub_sub_1a_cidr = "10.0.1.0/24"
pub_sub_2b_cidr =  "10.0.2.0/24"
pri_sub_3a_cidr = "10.0.3.0/24"
pri_sub_4b_cidr = "10.0.4.0/24"
certificate_domain_name = "davidinsider.com"
additional_domain_name = "terraform.davidinsider.com"

variables.tf

variable region {}
variable project_name {}
variable vpc_cidr {}
variable pub_sub_1a_cidr {}
variable pub_sub_2b_cidr {}
variable pri_sub_3a_cidr {}
variable pri_sub_4b_cidr {}
variable certificate_domain_name {}
variable additional_domain_name {}

Deployment

Terraform

Overview

Terraform is an infrastructure as code (IaC) tool. It lets you manage infrastructure fast, much faster than clicking through the console. You describe what you want in HashiCorp Configuration Language (HCL).

Deployment Steps

  1. Set Up the Backend on AWS:

In backend.tf I store the state file in an S3 bucket and use DynamoDB for locking. For that to work, the bucket and table have to exist first. I made them in the AWS console. Set the partition key to LockID so AWS can lock the state file properly.

  1. Gitignore:
*.terraform
modules/key/client_key
*.tfvars
modules/key/client_key.pub
*.tfstate

I gitignore files that don’t need to be in the repo, get generated automatically, or hold sensitive info.

  • .tfstate files

terraform.tfstate holds the current state of the infrastructure. It gets generated when you run terraform apply and tells Terraform what needs to change. Mine lives in an S3 bucket and is locked by DynamoDB.

  • .tfvars files

You can keep variables in a .tfvars file, but it’s not recommended. I did because nothing in mine is sensitive. I still gitignore it out of habit.

  • .terraform.lock.hcl

This file pins the exact provider versions (AWS). It gets generated when you run terraform init and makes sure everyone uses the same versions.

  • .terraform directory

This holds the plugins and modules the project uses. It also holds the state file and gets created when you run terraform init. It keeps the plugins and modules consistent.

  • Key files

Pretty obvious. Don’t keep your keys in your repo.

  1. Init, Plan, and Apply
terraform init
terraform plan
terraform apply

4. Destroy

terraform destroy

Wrapping Up

I enjoyed documenting this project and playing around with AWS, especially anytime I get to use Terraform. Whether you’re new to cloud or not, AWS and Terraform make it quick to set up infrastructure. Even for a classic game like Tetris.