Terraform and Amazon Web Services: first steps

The time finally came for me to dive into some infrastructure-related topics, so I decided to set up a local playground to learn a bit more about Terraform and how it interacts with Amazon Web Services as a provider.

Pre-requisites

Terraform CLI

First things first, get Terraform on your machine. You can find here the guide to install Terraform for your platform.

Run this last to verify your setup is correct:

terraform -help

Amazon Web Services account

You will also need an AWS account. If you are interested in learning how to use Terraform in combination with AWS, it's best that you start by using it in combination with AWS. I say that because as of now, there are no good AWS replacements that still honor the same API which you can use locally to play around.

This might sound obvious, but Terraform interfaces with cloud providers via what they call Providers. These are pieces of software that act as an abstraction layer between the end cloud provider and Terraform (core).

This feature allows a Terraform user to write HCL (HashiCorp Configuration Language) and be able to interface with any cloud provider regardless of their particularities for the most part.

If you don't want to spend money while testing, you can check Amazon's Free Tier page to get started with the account creation. There are some instances you can use for free up to some extent.

When you have your account ready, create an Access Key to use with Terraform.

Keep this key safe as we will need it very soon :)

First terraforming to test the waters

Create an empty folder and an empty file inside called main.tf with this content:

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

# Configure the AWS Provider
provider "aws" {
  region = "eu-west-1" // Ireland
}

And now let's start by running in that new directory:

terraform init

This will initialize Terraform for this project.

Terraforming our first server instance

Telling AWS what kind of machine or service we want

Next, we'll be prompted to run terraform plan so as to see what would happen in a real scenario.

We can run that and we'll be shown:

No changes. Your infrastructure matches the configuration.

That's because we didn't have any changes in our main.tf file between runs, so Terraform recognizes there are no changes to be done either on the cloud provider's side.

In order to deploy our first machine to AWS, we are going to need an ID for the kind of machine we want to start via Terraform. We need that ID to tell Amazon what characteristics we want our machine to have like which OS it should run, what level of performance it should provide, how much storage it should have, etc.

An AMI ID looks like this: ami-058b1b7fe545997ae.

This is a real ID from an EC2 instance, with Ubuntu as the OS and it's also part of the Amazon's free tier when choosing t2.micro as the instance type. Since these IDs change often, it's best you get one from the instance list within the AWS console yourself to be sure that you are getting what you want.

Let's add this new instance at the bottom of our main.tf:

resource "aws_instance" "test_app_server" {
    ami           = "ami-058b1b7fe545997ae"
    instance_type = "t2.micro"
}

You can see we used a "Resource" to accomplish that. Resources are probably the most important piece in Terraform as they are used to describe anything running on the cloud provider. That can be a server, but can also be a VPC or a gateway, etc.

With the minimal configuration ready, let's run terraform plan and we will obtain an output like the following:

See full console output
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_instance.test_app_server will be created
  + resource "aws_instance" "test_app_server" {
      + ami                                  = "ami-058b1b7fe545997ae"
      + arn                                  = (known after apply)
      + associate_public_ip_address          = (known after apply)
      + availability_zone                    = (known after apply)
      + cpu_core_count                       = (known after apply)
      + cpu_threads_per_core                 = (known after apply)
      + get_password_data                    = false
      + host_id                              = (known after apply)
      + id                                   = (known after apply)
      + instance_initiated_shutdown_behavior = (known after apply)
      + instance_state                       = (known after apply)
      + instance_type                        = "t2.micro"
      + ipv6_address_count                   = (known after apply)
      + ipv6_addresses                       = (known after apply)
      + key_name                             = (known after apply)
      + outpost_arn                          = (known after apply)
      + password_data                        = (known after apply)
      + placement_group                      = (known after apply)
      + primary_network_interface_id         = (known after apply)
      + private_dns                          = (known after apply)
      + private_ip                           = (known after apply)
      + public_dns                           = (known after apply)
      + public_ip                            = (known after apply)
      + secondary_private_ips                = (known after apply)
      + security_groups                      = (known after apply)
      + source_dest_check                    = true
      + subnet_id                            = (known after apply)
      + tags_all                             = (known after apply)
      + tenancy                              = (known after apply)
      + vpc_security_group_ids               = (known after apply)

      + capacity_reservation_specification {
          + capacity_reservation_preference = (known after apply)

          + capacity_reservation_target {
              + capacity_reservation_id = (known after apply)
            }
        }

      + ebs_block_device {
          + delete_on_termination = (known after apply)
          + device_name           = (known after apply)
          + encrypted             = (known after apply)
          + iops                  = (known after apply)
          + kms_key_id            = (known after apply)
          + snapshot_id           = (known after apply)
          + tags                  = (known after apply)
          + throughput            = (known after apply)
          + volume_id             = (known after apply)
          + volume_size           = (known after apply)
          + volume_type           = (known after apply)
        }

      + enclave_options {
          + enabled = (known after apply)
        }

      + ephemeral_block_device {
          + device_name  = (known after apply)
          + no_device    = (known after apply)
          + virtual_name = (known after apply)
        }

      + metadata_options {
          + http_endpoint               = (known after apply)
          + http_put_response_hop_limit = (known after apply)
          + http_tokens                 = (known after apply)
        }

      + network_interface {
          + delete_on_termination = (known after apply)
          + device_index          = (known after apply)
          + network_interface_id  = (known after apply)
        }

      + root_block_device {
          + delete_on_termination = (known after apply)
          + device_name           = (known after apply)
          + encrypted             = (known after apply)
          + iops                  = (known after apply)
          + kms_key_id            = (known after apply)
          + tags                  = (known after apply)
          + throughput            = (known after apply)
          + volume_id             = (known after apply)
          + volume_size           = (known after apply)
          + volume_type           = (known after apply)
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Using our credentials for AWS

Before we can execute this plan in Amazon's infrastructure, we are going to need to use that key pair we generated in the setup part before.

You can find how to use them in the credentials part of their documentation and we are going to take the "unsafe" way by directly embedding them in the main.tf file just for simplicity purposes.

Update your provider block like this:

provider "aws" {
  region = "eu-west-1" // Ireland
  access_key = "YOUR_PUBLIC_KEY"
  secret_key = "YOUR_SECRET_KEY"
}

In a real scenario, you should use the environment variables approach to avoid exposing your credentials in the main.tf file.

Let's deploy!

Let's run terraform apply now and we will be asked if we want to apply the changes presented, which should be the same as the ones we saw when terraform planing before.

We will accept those, and then we'll see something like this:

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_instance.test_app_server: Creating...
aws_instance.test_app_server: Still creating... [10s elapsed]
aws_instance.test_app_server: Still creating... [20s elapsed]
aws_instance.test_app_server: Still creating... [30s elapsed]
aws_instance.test_app_server: Still creating... [40s elapsed]
aws_instance.test_app_server: Creation complete after 43s [id=i-059e3b8e7485e76ee]

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

Yay! Our first deployment with Terraform and Amazon Web Services!

Beyond the baby steps

Removing an instance

Okay, we managed to deploy something but what if we want to decomission this server now?

We would have two alternatives here. One would be to completely destroy what was built with this file, while the other would be to manually update the Terraform configuration file (main.tf) to remove that specific server and then update the state of our cloud provider.

Let's first attempt to update the main.tf file to not contain our first server anymore by removing the resource block and running terraform plan.

We'll get an output like this:

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # aws_instance.test_app_server will be destroyed
  - resource "aws_instance" "test_app_server" {
      - ami                                  = "ami-058b1b7fe545997ae" -> null
      ... many more lines of updates...
    }

    Plan: 0 to add, 0 to change, 1 to destroy.

Because of the declarative nature of HCL, we can just remove the resource and run terraform apply again to get rid of that instance.

However, we are not going to apply it, because we want to see how it would work with the other command that would achieve the same effect in this case: terraform destroy.

Let's run terraform destroy and we get the same output as before, but this time we will type "yes" and apply the changes to remove the server.

We can see now that our instance got removed in the output of the terminal, but we can also make sure by checking the AWS console.

It's recommended that before you stop playing with Terraform and AWS you make sure you terminated all instances or services you were running to not incur into extra charges on your account.

Workflow

As we have seen, any operation is based on the comparison of the current state with the new state dictated by the configuration file.

Based on this, we could just ignore commands like destroy and always rely on apply for all scenarios, as everything is just an update from the previous state.

Final thoughts

There are many more things to know about Terraform for a real world use-case, but explaining them falls out of the scope of this introductory blogpost.

That being said, before closing I will mention a couple of topics at least that would be interesting to visit if you have decided to move forward with this stack but need more advanced features: