What is this all about?

In this post, we're going to talk about how to setup a beach head in the AWS cloud with a simple EC2 instance, spun up painlessly and repeatably, with Terraform. Terraform is the poster child of Infrastructure as Code, a DevOps process that's being quickly adopted as it provides not only easy, but more importantly repeatable infrastructure roll outs via a fairly straight forward (if not quite a programming language) definition that can be easily thrown into a Git repo and thus tracked as if all the infrastructure being managed was simply code objects in a giant application. There are "providers" for all sorts of things, from AWS to F5, to Dominos Pizza. Today, we're going to focus on the AWS provider, and more importantly, I'm going to show you how you can easily spin up an EC2 instance that's routable and could be easily configured to start serving web traffic today.

Sounds great, where do we start?

First off, I really recommend that you're going to want a git repo for this. It just really helps out to manage, publish, and track your infrastructure if you treat it as real code. (You could skip this step, but being that we will spin up AND tear down this infrastructure from this code I recommend it as running infrastructure in AWS costs money and not having the code around to run the tear down later would just make more work for you).

Whats this Terraform code look like?

Okay, I've got my GitHub "congenial-octo-goggles" Repo, now can we start? First off, let's talk a little about the way Terraform declares blocks of code.

resource "resource_type" "your_name_for_the_resource" {
  count = 1
  a_setting = "A string"
  a_list = ["foo","bar"]
  a_map = { version = 38, name = "Fizzbuzz" }
  a_number = 42
  a_bool: true
  option_block {
     like_options: true
     are_sometimes_in_blocks: 12345
   }
}

You get the idea... if you've ever done much programming lately, I'm sure it looks pretty familiar, but remember it's not QUITE a programming language. These are resource defintion blocks. There's a few different kinds of blocks we use (we'll get into that later), but don't think you can go crazy with if/then/else and async/await blocks or something. You can get creative with bits to do some on the fly deduction, and later you'll see you can even do string manipulation and lookups, so it's still pretty powerful!

Ready to create some resources?

I'm sure you're itching to get something in AWS spun up, but we have ONE more thing. Terraform is a stateful language, meaning it tracks what it's done, what's in code, and what's running and then rectifies that on "apply". This means it needs somewhere to store it's state file. Personally, I highly recommend you store that in an aws s3 bucket. There are a few other backends supported, but s3 is quick, straight-foward, and you'll be able to get to it again when your computer dies. (seriously, commit this code to a git repo, and your state to s3 and it won't matter where you are or what computer you're running from, you can update your infrastructure.)

To put your state in s3, first setup an aws s3 bucket that you can access, and then throw it in the first file we'll create in this repo, backend.tf:

terraform {
  backend "s3" {
    bucket = "<this_is_your_state_file_bucket>"
    key    = "us-east-2"
    region = "us-east-2"
  }
}

Once you've got that, we'll create our next file, providers.tf.

# Configure the AWS Provider
provider "aws" {
  profile    = var.aws_profile
  region     = "us-east-2"
}

Yeap, we can put comments in there with either the # or // or /* */ syntax. It comes in really handy when things get complicated. Anyway, this file tells terraform we want to use the aws provider. We can even add a version or other attributes when needed, but this one is good. You'll notice something odd in that though, I didn't just use a string for the 'profile'... I specified var.aws_profile? What's that mean? Well.. it's a variable, and we're going to declare it here, in the brand new variables.tf file:

variable "aws_profile" {  
  type = string
  description = "AWS Profile"
  default     = "default"
}

Yeap, that's a variable block. We can declare what ever we want as a variable, and when I mean variable, I mean commandline variable. If we didn't set a default, you'd have to add -var aws_profile=default to your terraform run. However, throwing this in there not only teaches you about variables (Ha! Got you!), but it also allows you to run this against multiple locally stored aws profiles you've configured with the aws cli.

Initializing Terraform

We now have enough coded to run a meaningful terraform command. Let's try it!

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Installing hashicorp/aws v3.44.0...
- Installed hashicorp/aws v3.44.0 (self-signed, key ID 34365D9472D7468F)

Partner and community providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://www.terraform.io/docs/cli/plugins/signing.html

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.

Whoohoo, we're ready to go. Let's create some resources!

The Requirements

So, we want to make an EC2 instance. But any good ec2 instance needs a network connection. And a network connection needs a NIC, and a NIC needs an IP and an IP needs a subnet, and a subnet needs a CIDR block... and we get all that in a VPC. VPC's are AWS's "Virtual Private Cloud"s, meaning that they allow you to define where your instances and other resources will run.

resource "aws_vpc" "my_vpc" {
  cidr_block       = "192.168.16.0/24"

  tags = {
    Name = "Primary VPC"
  }
}

# This is 64 addresses for external
resource "aws_subnet" "dmz" { 
  vpc_id     = aws_vpc.my_vpc.id
  cidr_block = "192.168.16.0/26"

  tags = {
    Name = "DMZ Subnet"
  }
}

Now, we've declared a VPC and we told terraform we want it to have a 64 IP address subnet at 192.168.16.0/26. Obviously, this can be whatever you want, but you'll notice another interesting bit here... the vpc_id is a variable, but not one that starts with "var". That's because it's a resource lookup. The subnet block uses the id that it got from the vpc, because aws gives it a random one, and it wants to point to that one.

The syntax here is pretty straight forward: ... So in this case, we're saying "Hey, we delcared an aws_vpc resource, and named it my_vpc, can you snag that 'id' for me and stick it here?". Terraform does the lookup and populates the field. It also makes note that since the aws_subnet refers to the aws_vpc, it'll have to build that up and tear it down in the right order, what's called a dependency graph. This is one of the huge benefits of using something like Terraform to make your infrastructure. In a complex setup, you'd have to do a LOT of clicking around in the AWS web console to try to clean up things later. With this dependency graph in place, a simple terraform destroy can do all that for you. Plus, if you later decide you don't want that subnet, but want others, it can figure all that out for you!

Other necessities

resource "aws_security_group" "webserver" {
  name        = "Webserver-SG"
  description = "Allow HTTP(s) inbound traffic"
  vpc_id      = aws_vpc.my_vpc.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 80
    to_port     = 80
    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"]
  }
}
resource "aws_route_table_association" "dmz_assoc" {
  subnet_id      = aws_subnet.dmz.id
  route_table_id = aws_route_table.dmz_route_table.id
}

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

  tags = {
    Name = "Eastern VPC Internet Gateway"
  }
}

resource "aws_route_table" "dmz_route_table" {
  vpc_id = aws_vpc.thejml_east_vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

#  route {
#    cidr_block = aws.thejml_east_vpc.cidr

#  route {
#    ipv6_cidr_block        = "::/0"
#    egress_only_gateway_id = aws_egress_only_internet_gateway.foo.id
#  }

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

Finally, we make an instance

resource "aws_instance" "omniserver" {
  ami                         = var.ami
  instance_type               = "t3.micro"
  associate_public_ip_address = true

  subnet_id              = aws_subnet.dmz.id
  vpc_security_group_ids = [aws_security_group.webserver.id, aws_security_group.ssh.id]

  iam_instance_profile = aws_iam_instance_profile.webserver.name

  key_name = aws_key_pair.webserver.key_name

  disable_api_termination = true
  ebs_optimized           = false
  hibernation             = false
  monitoring              = false

  credit_specification {
    cpu_credits = "unlimited"
  }

  enclave_options {
    enabled = false
  }

  metadata_options {
    http_endpoint               = "enabled"
    http_put_response_hop_limit = 1
    http_tokens                 = "optional"
  }

  root_block_device {
    volume_type = "gp2"
    volume_size = 30
  }

  tags = {
    Name                = "Test Webserver"
    key                 = "purpose"
    value               = "webserver"
    propagate_at_launch = true
  }
}

resource "aws_key_pair" "webserver" {
    key_name = "Webserver host key"
    public_key = "ssh-rsa <REDACTED HASH>"
}

Let's add an Output

So, it turns out that things in Terraform state are kinda useful for us humans as well. For example, the IP we just got for that webserver. It'd be really handy to just know that from the command line and do something with it instead of having to open up the AWS web console to snag it every time. It ALSO turns out that it's easy for terraform to tell you!

output "elastic_ip" {
  value = aws_eip.webserver_eip.*.public_ip
  description = "Elastic IP for Omniserver"
} 

Adding that will allow you to run:

$ terraform output elastic_ip
[
  "18.212.152.231",
]

It's a json doc, so you can either import that into another script, or flatten/parse it with jq. If you leave elastic_ip off, it'll just print out all of your defined outputs.

Welp, that costs money, do you're thing "destroy"!

At this point, if you're ready to clean things up, all you have to do is run:

$ terraform destroy

As usual, it'll say what it's going to do, and then upon your confirmation, nuke everything we just built. Of course, since you have the code, you can do it again whenever you want!

Next Steps

We'll be covering some of these topics in the coming weeks so stay tuned!

  • Connecting other AWS Services (RDS+Elasticache = LAMP!)
  • Packer to create custom AMI's
  • Using SSM to connect to your consoles so you don't have to open port 22 to the world
  • Making your instances resilient with Launch Templates and Autoscalers
  • Keeping an eye on things with CloudWatch
  • Containers on demand with Lambda
  • Containers in AWS with Fargate
  • Kubernetes in AWS with EKS