This guide walks you through automating the creation and management of an AWS EC2 instance (and supporting resources) using Terraform.
Disclaimer
This example creates AWS resources.If you don’t want to incur charges, please destroy them after this tutorial.
Prerequisites
- AWS Account where you have access to create the necessary resources.
- Basic knowledge of Terraform. This tutorial won’t go into the language details.
- Install Terraform.
- AWS (session) credentials. To check if these are setup, you can print the environment
$ printenv | grep AWS
- Code editor of your choice. If you’re using VSCode/VSCodium, I’d recommend setting up the Terraform plugin.
Anatomy
Let’s take a look at the seven components that will be deployed.

VPC: First, there’s the VPC. This is a virtual private network in the cloud that only we have access to.
- Almost all resources are deployed in a VPC.
- If a VPC is not defined, the default one in the region is used.
Subnet: Subnets are divisions of VPCs to separate resources. So you can make some subnets public and expose them to the internet and some can be kept private, for example to deploy databases in.
EC2 Instance: Then there is EC2 instance, which is the virtual machine. AWS offers options to configure the amount of vCPU, Memory, disc space, I/O, architectures and operating systems.
Security Group: An AWS Security group is a firewall, which allows us to control what traffic goes in and out of the machine.
Route Table: There’s a route table. This is a lookup table to see what IP addresses are routed where.
Internet Gateway: A VPC by default is sealed off from the internet. We need to manually open the VPC to the internet. An Internet Gateway is like the door that does this.
Elastic IP: AWS EC2 machines sometimes have a public IP attached when they are created. But they will get destroyed when the machine gets destroyed. We can purchase a public IP separately and attach/detach that to/from a machine. Such an IP is called an Elastic IP.
Code
First go to the Hashicorp registry documentation. This is really handy to refer to.
Setup
Provider
In the root folder, create a main.tf, variables.tf and a providers.tf file.
Create a ec2-deploy.auto.tfvars.json. This is where the deployment configuration lies.
First create the region variable in variables.tf.
variable "region" {
description = "The AWS Region"
type = string
default = "eu-west-1"
}
First setup the provider in the providers.tf file and use the region variable.
terraform {
required_version = ">= 1.12.2"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
# Configuration options
}
In this example, we are not going to backup the state file as this is a test setup. But if you were going to store it in AWS S3 for example, the
provider "aws"block is where the bucket will be configured.
Common Variables
Let’s first create a name variable in variables.tf. This name will be added to most resources to better identify them.
Let’s also create a tags variable.
AWS tags are key-value pairs that are very useful to identify and track resources.
variable "name" {
description = "The name of the deployment"
type = string
default = "my-website"
}
variable "tags" {
description = "Additional tags to apply to all resources that support tagging."
type = map(string)
default = {}
}
Networking
VPC
Search for VPC in the docs or use this link. This will give you a list of all options.
The most important option is the CIDR block.
CIDR block is a block of IP addresses from which AWS can assign IP address to different network interfaces. At this point, I’d recommend using the default value of 10.0.0.0/16.
This provides 2^16 possible IP addresses, which is quite a lot.
Let’s create a variable in variables.tf.
variable "vpc_cidr_block" {
description = "The primary IPv4 CIDR block for the VPC."
type = string
default = "10.0.0.0/16"
}
Now in main.tf, let’s use this.
resource "aws_vpc" "vpc" {
cidr_block = var.vpc_cidr_block
tags = local.tags
}
Subnet
Search for Subnet in the docs or use this link. This will give you a list of all options.
There are two important options. The VPC ID and once again a CIDR block.
But this block should be a subset of VPC CIDR block. We can either select this manually or use Terraform’s built-in cidrsubnet function.
This is a bit tricky but the following diagram explains how the subnets are divided.
TODO: ADD HAND DRAWN DIAGRAM
Now let’s create this subnet.
resource "aws_subnet" "public" {
vpc_id = aws_vpc.vpc.id
cidr_block = cidrsubnet(var.vpc_cidr_block, 8, 0)
tags = local.tags
}
Since we are going to make this subnet public, let’s call it that.
Internet Gateway
Search for Internet Gateway in the docs or use this link. This will give you a list of all options.
This is pretty straightforward. We just need to attach it to the correct VPC.
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.vpc.id
tags = local.tags
}
Route Table and Routes
Search for Route Table in the docs or use this link. This will give you a list of all options.
This is also very easy. We are going to set one public route to route all traffic 0.0.0.0/0 to the internet gateway.
resource "aws_route_table" "public" {
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
tags = local.tags
}
We also need to tell the Route table that this public route should be forwarded to the public subnet. That’s done using a route table association.
resource "aws_route_table_association" "association" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
Security Group
Search for Security Group in the docs or use this link. This will give you a list of all options.
The security group is the firewall. We can define traffic what IP addresses and what ports to allow into the machine.
For this example, let’s allow full SSH access inbound access (ingress) and full outbound (egress).
Note: Such broad SSH access is insecure. In production either restrict the IP to a list of known IPs or use a service like Wireguard, TailScale or even AWS Session manager. This is a test installation, so we keep it as it is.
The description field is mandatory for Security groups.
resource "aws_security_group" "sg" {
name = ${var.name}
description = "EC2 Instance Security Group"
vpc_id = aws_vpc.vpc.id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "SSH access"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = local.tags
}
EC2 Machine
Search for EC2 Instance in the docs or use this link. This will give you a list of all options.
Here there are a few dozen options. But this example will use only the most important one.
Let’s first create a few variables in variables.tf for the instance type and the disk size.
variable "server_instance_type" {
description = "The type of instance to start. See https://aws.amazon.com/ec2/instance-types/ for more information."
type = string
default = "t4g.nano"
}
variable "disk_size" {
description = "The size of the instance's disk."
type = number
default = 20
}
For this example, I’ll use t4g.nano instance, which is the smallest burstable instance at the time of writing.
We also need to use an AMI (Amazon Machine Image) is an OS image with some customizations; for example to run scripts at the start up. If you have an image you can use the ID.
Here I’m going to use the latest Amazon Linux 2023 image. Instead of searching the AMI ID online and hardcoding this, we can use a Data source to fetch this during deployment.
# Fetch the most recent Amazon Linux 2023 (AL2023) AMI.
data "aws_ami" "amazon_linux_2023" {
most_recent = true
filter {
name = "name"
values = ["al2023-ami-2023*-arm64"]
}
owners = ["137112412989"] # Amazon
}
We can now create the instance by referring to this.
resource "aws_instance" "ec2_instance" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = var.server_instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.sg.id]
root_block_device {
volume_size = var.disk_size
encrypted = true
}
credit_specification {
cpu_credits = "standard"
}
lifecycle {
ignore_changes = [
ami # The "current" latest version of the AMI may not be the one that was used during deployment.
]
}
tags = local.tags
}
Two things to note:
- The credit specification is only for burstable instances.
- The data block will return the latest image all the time. So if it fetches an image that is newer than what you installed, it might force your machine to be redeployed. So let’s avoid that by adding an ignore block.
Elastic IP
Search for Elastic IP in the docs or use this link. This will give you a list of all options.
resource "aws_eip" "public_ip_address" {
depends_on = [aws_instance.ec2_instance]
tags = local.tags
}
Associate this with the machine.
resource "aws_eip_association" "eip_assoc" {
instance_id = aws_instance.ec2_instance.id
allocation_id = aws_eip.public_ip_address.id
}
Review/Summary
Check the code in the Github repo to compare it with yours.
Deployment
First initialize the Terraform state.
$ terraform init
Create the project specific values in ec2-deploy.auto.tfvars.json. The other variables are left default, but you can set it.
{
"name": "youtube-test",
"tags": {
"env": "dev",
"purpose": "learning",
"maintainer": "krishna"
}
}
Plan the deployment.
$ terraform plan
If there are no errors, execute the plan.
$ terraform apply
Checking
Head over to the AWS console to your region and check that the resources, along with the proper tags are created.
You can now SSH using AWS Instance Connect and run a few commands on your machine.
Destroy
AWS resources like EC2 instances can incur charges if you don’t have an AWS free tier. So let’s destroy that.
If we were setting up these resources manually, we’d need to carefully delete them in the reverse order of creating them and them, paying careful attention to dependencies.
But with Terraform, this can be done in a single command.
$ terraform destroy
Summary
Here,
- We learnt the anatomy of the resources requried to setup an EC2 instance.
- We referred to the Terraform registry docs and described them in Terraform.
- Then we deployed them and tested that they work.
- Then we destroyed them in a single command.