When it comes to quickly provision a server in the Cloud, using an Infrastructure as Code (IaC) tool is a solution to consider.
There are many IaC products available and among them, Terraform seems to be the most popular.

The following is a non-exhaustive list of Terraform advantages :
– Terraform can deploy infrastructure to multiple cloud service providers simultaneously
– With Terraform, you can easily preview and validate infrastructure changes before they are applied
– The code is written in a declarative way. Not procedural
– Terraform creates immutable infrastructure (using configuration snapshots)


In this post, we are going to deploy an AWS EC2 instance and all the related network components (and their features) required to access it through SSH.
For that it’s necessary to deploy following AWS services :
– VPC (Virtual Private Cloud)
– Security Group
– Subnet
– Route Table
– Internet Gateway
– And finally the EC2 instance

For a better manageability I do recommend to create one dedicated file per service listed above. This will also give a good overview what the Terraform Configuration is made of :

joc@joc:$ tree
.
├── aws-config.tf
├── create-igw.tf
├── create-instance.tf
├── create-rt.tf
├── create-sbn.tf
├── create-sg.tf
├── create-vpc.tf
├── variables_output.tf
└── variables.tf

0 directories, 9 files

 

Variables

Using variables in a Terraform Configuration is a good practice. Like with all other tools and programming languages, their purpose is to create a value only once and to reuse it across the whole deployment configuration.
The best is to create a dedicated file to store them. Here’s what mine looks like :

# variables.tf

# Variables for general information
######################################

variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "eu-central-1"
}

variable "owner" {
  description = "Configuration owner"
  type        = string
}

variable "aws_region_az" {
  description = "AWS region availability zone"
  type        = string
  default     = "a"
}


# Variables for VPC
######################################

variable "vpc_cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "vpc_dns_support" {
  description = "Enable DNS support in the VPC"
  type        = bool
  default     = true
}

variable "vpc_dns_hostnames" {
  description = "Enable DNS hostnames in the VPC"
  type        = bool
  default     = true
}


# Variables for Security Group
######################################

variable "sg_ingress_proto" {
  description = "Protocol used for the ingress rule"
  type        = string
  default     = "tcp"
}

variable "sg_ingress_ssh" {
  description = "Port used for the ingress rule"
  type        = string
  default     = "22"
}

variable "sg_egress_proto" {
  description = "Protocol used for the egress rule"
  type        = string
  default     = "-1"
}

variable "sg_egress_all" {
  description = "Port used for the egress rule"
  type        = string
  default     = "0"
}

variable "sg_egress_cidr_block" {
  description = "CIDR block for the egress rule"
  type        = string
  default     = "0.0.0.0/0"
}


# Variables for Subnet
######################################

variable "sbn_public_ip" {
  description = "Assign public IP to the instance launched into the subnet"
  type        = bool
  default     = true
}

variable "sbn_cidr_block" {
  description = "CIDR block for the subnet"
  type        = string
  default     = "10.0.1.0/24"
}


# Variables for Route Table
######################################

variable "rt_cidr_block" {
  description = "CIDR block for the route table"
  type        = string
  default     = "0.0.0.0/0"
}


# Variables for Instance
######################################

variable "instance_ami" {
  description = "ID of the AMI used"
  type        = string
  default     = "ami-0211d10fb4a04824a"
}

variable "instance_type" {
  description = "Type of the instance"
  type        = string
  default     = "t2.medium"
}

variable "key_pair" {
  description = "SSH Key pair used to connect"
  type        = string
  default     = "joc-key-pair"
}

variable "root_device_type" {
  description = "Type of the root block device"
  type        = string
  default     = "gp2"
}

variable "root_device_size" {
  description = "Size of the root block device"
  type        = string
  default     = "50"
}

Each of these variable will be used to describe our environment and they will need to be prefixed by var. when we will be using them.
Let’s start with the first step.

Provider

As already said, Terraform supports infrastructure deployment to several Cloud providers. Thus, the first things to do in our Configuration is to define the provider we want to use.
As the providers’ API have several versions available, it’s important to define a version constraint to force Terraform to select a single version
that all parts of your configuration are compatible with. If you don’t do that you take the risk to use a newer version that might not be compatible with your Configuration.
A good way to force the maximum version we want to work with is to use the ~> operator. This will allow to use only patch releases within a specific minor release :

# aws_config.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.26"
    }
  }
}

provider "aws" {
  profile = "default"
  region  = var.aws_region
}

As I don’t set my AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (security), Terraform will use the one coming from my environment variables.

VPC

VPC (Virtual Private Cloud) is one of the fundamental component to create when starting an AWS project. It allows to launch resources in a virtual and isolated network. Creating a VPC is optional, but it becomes mandatory when you want to create interconnections with others networks or if you want to isolate some EC2 instances in a subnet unreachable from outside :

# create-vpc.tf

resource "aws_vpc" "vpc" {
  cidr_block           = var.vpc_cidr_block
  enable_dns_hostnames = var.vpc_dns_hostnames
  enable_dns_support   = var.vpc_dns_support

  tags = {
    "Owner" = var.owner
    "Name"  = "${var.owner}-vpc"
  }
}

This defines the IP range that will be used and enable DNS support and hostname so the instance can get a DNS name.

Security Group

To control inbound and outbound access to an EC2 instance, it’s required to create a Security Group.
A Security Group is like the local firewall of the instance. If we want to be able to connect to the instance via SSH we need to create an ingress rule allowing our IP to connect to TCP port 22.
In order to be able to connect to the instance from wherever we are located (home, office, …), let’s store our current public IP using the data sources module of Terraform. Then it can be used as a variable to define the value of the cidr_blocks argument in the ingress attributes :

# create-sg.tf

data "http" "myip" {
  url = "http://ipv4.icanhazip.com"
}

resource "aws_security_group" "sg" {
  name        = "${var.owner}-sg"
  description = "Allow inbound traffic via SSH"
  vpc_id      = aws_vpc.vpc.id

  ingress = [{
    description      = "My public IP"
    protocol         = var.sg_ingress_proto
    from_port        = var.sg_ingress_ssh
    to_port          = var.sg_ingress_ssh
    cidr_blocks      = ["${chomp(data.http.myip.body)}/32"]
    ipv6_cidr_blocks = []
    prefix_list_ids  = []
    security_groups  = []
    self             = false

  }]

  egress = [{
    description      = "All traffic"
    protocol         = var.sg_egress_proto
    from_port        = var.sg_egress_all
    to_port          = var.sg_egress_all
    cidr_blocks      = [var.sg_egress_cidr_block]
    ipv6_cidr_blocks = []
    prefix_list_ids  = []
    security_groups  = []
    self             = false

  }]

  tags = {
    "Owner" = var.owner
    "Name"  = "${var.owner}-sg"
  }
}

A second rules exists in this Security Group to allow the instance to connect outside.

Subnet

A subnet must be created inside the VPC, with its own CIDR block, which is a subset of the VPC CIDR block :

#create-sbn.tf

resource "aws_subnet" "subnet" {
  vpc_id                  = aws_vpc.vpc.id
  cidr_block              = var.sbn_cidr_block
  map_public_ip_on_launch = var.sbn_public_ip
  availability_zone       = "${var.aws_region}${var.aws_region_az}"

  tags = {
    "Owner" = var.owner
    "Name"  = "${var.owner}-subnet"
  }
}

If you plan to create more than one subnet, think about deploying them into different Availability Zones.

Internet Gateway

We also need an Internet Gateway to enable access over the Internet.

# create-igw.tf

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    "Owner" = var.owner
    "Name"  = "${var.owner}-igw"
  }
}

If the traffic of a subnet is routed to the Internet Gateway, the subnet is known as a public subnet. That means that all instances connected to this subnet can connect the Internet through the Internet Gateway.
To define this association, we need a Route Table :

Route Table

#create-rt.tf

resource "aws_route_table" "rt" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = var.rt_cidr_block
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    "Owner" = var.owner
    "Name"  = "${var.owner}-rt"
  }

}

The link between the subnet and the route table is done by creating an association :

resource "aws_route_table_association" "rt_sbn_asso" {
  subnet_id      = aws_subnet.subnet.id
  route_table_id = aws_route_table.rt.id
}

 

Instance

The network layer is now ready. We can create the EC2 instance in the subnet of our VPC :

# create-instance.tf

resource "aws_instance" "instance" {
  ami                         = var.instance_ami
  availability_zone           = "${var.aws_region}${var.aws_region_az}"
  instance_type               = var.instance_type
  associate_public_ip_address = true
  vpc_security_group_ids      = [aws_security_group.sg.id]
  subnet_id                   = aws_subnet.subnet.id
  key_name                    = var.key_pair

  root_block_device {
    delete_on_termination = true
    encrypted             = false
    volume_size           = var.root_device_size
    volume_type           = var.root_device_type
  }

  tags = {
    "Owner"               = var.owner
    "Name"                = "${var.owner}-instance"
    "KeepInstanceRunning" = "false"
  }
}

 

Init

Everything is ready. To start the deployment, we need first of all to initialize our working directory :

joc@joc:$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding latest version of hashicorp/http...
- Finding hashicorp/aws versions matching "~> 3.26"...
- Installing hashicorp/http v2.0.0...
- Installed hashicorp/http v2.0.0 (signed by HashiCorp)
- Installing hashicorp/aws v3.26.0...
- Installed hashicorp/aws v3.26.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

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.
joc@joc:$

 

Validate

Then we can check if the code of our Configuration is syntactically valid :

joc@joc:$ terraform validate
Success! The configuration is valid.

joc@joc:$

 

Plan

Terraform can show what will be deployed using the plan command. This is very useful to check all the resources before creating them.
As the output is quite long I’ll truncate it and keep only some lines :

joc@joc:$ terraform plan
var.owner
  Configuration owner

  Enter a value: joc


An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:
...
...
...
Plan: 7 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + public_ip = (known after apply)

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

joc@joc:~$

At line n° 5 we are prompted to give a name to the owner of the Configuration. This is because we didn’t set a default value to the variable “owner” (see variables.tf). As you may have noticed, this variable is regularly used to name the resources as well as to tag them. This will be helpful when working with these resources.

Apply

Finally we can apply the execution plan and everything will be created in few minutes only :

joc@joc:$ terraform apply
var.owner
  Configuration owner

  Enter a value: joc


An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:
...
...
...
aws_vpc.vpc: Creating...
aws_vpc.vpc: Creation complete after 3s [id=vpc-00aa8a9136306adf2]
aws_internet_gateway.igw: Creating...
aws_subnet.subnet: Creating...
aws_security_group.sg: Creating...
aws_internet_gateway.igw: Creation complete after 1s [id=igw-069cde646f3d4967f]
aws_route_table.rt: Creating...
aws_subnet.subnet: Creation complete after 1s [id=subnet-07034f2432203028e]
aws_route_table.rt: Creation complete after 0s [id=rtb-06fd67d98d0d8d24c]
aws_route_table_association.rt_sbn_asso: Creating...
aws_route_table_association.rt_sbn_asso: Creation complete after 1s [id=rtbassoc-04b8fd0f0984d648b]
aws_security_group.sg: Creation complete after 2s [id=sg-0c3547bc9189af478]
aws_instance.instance: Creating...
aws_instance.instance: Still creating... [10s elapsed]
aws_instance.instance: Still creating... [20s elapsed]
aws_instance.instance: Still creating... [30s elapsed]
aws_instance.instance: Creation complete after 33s [id=i-0c102b97c854bbb80]

Apply complete! Resources: 7 added, 0 changed, 0 destroyed.

Outputs:

public_ip = "18.194.28.183"
joc@joc:$

The last line displays the public IP of the newly created instance. The is done thanks to the output variable defined into the file variables_output.tf :

# variable_output.tf

# Variables to show after the deployment
#########################################

output "public_ip" {
  value = aws_instance.instance.public_ip
}

 

We should now be able to connect to the instance :

joc@joc:$ ssh -i "~/joc-key-pair.pem" [email protected]
The authenticity of host '18.194.28.183 (18.194.28.183)' can't be established.
ECDSA key fingerprint is SHA256:aFg3EBxHgGENRKvFyMpZbfFPbAqz0RRiZqpsXM8T1po.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '18.194.28.183' (ECDSA) to the list of known hosts.
[ec2-user@ip-10-0-1-194 ~]$

 

That’s it ! Let’s see what it looks like from the AWS Console…

VPC :

 

 

 

Security Group :

Subnet :

Internet Gateway :

Route Table :

Instance :