Complete intro to Infrastructure as Code with Terraform

autor

Dieter V.H.

published on

02/06/2021

service

Cloud Automation

Lucidus symbol
Lucidus cloud

This will be a lengthy and detailed post as this is a complete example of a moderately complex setup. This blog will combine some best practices scattered around the internet to create an extensive source of information.

For some companies, the process of defining infrastructure hasn’t changed much over the past few years. When you need a new virtual machine to deliver your software, someone will most likely hop into AWS, GCP, Azure or vSphere and start going through a wizard to define the required resources and configuration for this new machine.

This may be fine in a relatively small organization but as you scale up, this approach becomes harder and harder to maintain with many teams requiring infrastructure scaling, security policy changes or access changes.

Just like software delivery software has gotten more efficient, infrastructure as code did not stand still. A number of very useful technologies emerged in the last decade.

In this article I’d like to give an introduction to Terraform using Amazon AWS as our cloud provider.

What is Terraform?

According to the Terraform intro the definition is as follows:

Terraform is a tool for buidling, changing, and versioning infrastructure safely and efficiently.

In essence, Terraform is a command-line utility that uses configuration files to automate the deployment of infrastructure. By using these configuration files, you can essentially treat your infrastructure like any other codebase and store it in a Git repository. This means we can leverage the software development lifecycle advantages such as versioning, pull requests and automated build pipelines in order to build and evolve your infrastructure over time! Neat, right?

Why use Terraform?

On top of the aforementioned advantages, Terraform comes with the following advantages:

– Terraform is open source
– Can be used with popular cloud providers
– Can be extended to work with custom providers
– Configuration is easy to read
– Works incrementally
– Provides a ‘planning’ step providing certainty

Architecture

Before we start setting up Terraform and creating our configuration files, let’s have a quick look at the architecture we’ll be setting up. I opted to go for a VPC with a public and private subnet with 1 instance in each subnet.

VPC’s

There is 1 VPC defined: 10.0.0.0/16. Both subnets are defined within this VPC meaning AWS will be able to route traffic between the subnets.

Private subnet

The private subnet uses CIDR 10.0.0.0/24 meaning we can use IPs between 10.0.0.1 and 10.0.0.254

Public subnet

The private subnet uses CIDR 10.0.1.0/24 meaning we can use IPs between 10.0.1.1 and 10.0.1.254

Instances

Bastion is our public instance in the public subnet. We’ll assign it the IP 10.0.1.10.
Acceptance is our private instance in the private subnet. We’ll assign it the IP 10.0.0.10.

Gateways

For internet traffic to be able to reach the outside world, we need some gateways.

– The public subnet will use the Internet gateway
– The private subnet will use the NAT gateway

Elastic Ips

2 elastic IPs have to be provisioned:
– 1 elastic IP for the Bastion host in the public subnet
– 1 elastic IP for the NAT gateway in the public subnet

Security groups & routing tables

I want the Bastion host to be publicly accessible via SSH. The Acceptance host should only be accessible from the Bastion host. All hosts should be able to access the internet. For this to work we will define some security groups:

The routing tables must always send traffic meant for the VPC to local whilst sending internet traffic from the public and private subnets to the internet gateway and the NAT gateway respectively.

Setting up

The CLI

Setting up terraform is easy; simply download the binary for your platform and add it to your PATH so that it can be executed from the command line.

After setting this up, you should be able to use your terminal to get the Terraform version:

$ terraform -version
Terraform v0.12.28

AWS credentials

In order for Terraform to be able to connect to AWS, we need to set up some environment variables:

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION

The AWS documentation describes how to generate the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. The AWS_DEFAULT_REGION will be where you want your infrastructure to run.

You’ll want to set up these environment variables on whatever OS you’re using. In my case it’s:

export AWS_ACCESS_KEY_ID="XXXXXXXXXXX"
export AWS_SECRET_ACCESS_KEY="YYYYYYYYYYYYYYYYYYYYYYY"
export AWS_DEFAULT_REGION="eu-central-1"

Planning our codebase

Initially, I started out with a single terraform (main.tf) file but quickly found that the single file became large and hard to read. I moved to a more well-defined structure by using modules.

You can decide what you want the structure of your modules to look like but I opted for a structure that reflected where in the AWS web interface you can find the different resources.

In this case, the modules we’ll need are:
– ec2
– images
– instances
– vpc
– elastic-ips
– internet-gateways
– nat-gateways
– route-table-associations
– routing-tables
– security-groups
– subnets
– vpcs

Depending on the complexity, it may also be a good idea to structure these modules according to your architecture rather than by resource type. The above structure is not guaranteed to work in your case.

A word on modules and how they work

The Terraform documentation suggests that each module contains at least 3 files (even if they are empty):

main.tf containing the core of the module
variables.tf containing the input variables this module expects
outputs.tf containing the variables this module outputs

Defining modules

A module can be defined simply by adding the following to any .tf file:

module "<module name>" {
  source = "./modules/ec2"             <-- The directory where the module resides
  default_tags = var.default_tags      <-- Add any variables the module expects
}

Variables in modules

Variables are the means of passing information from one module to another. Passing information between modules must be done explicitly, both on the ‘sender’ and the ‘receiver’ side so to speak. The module that injects the variables must do so explicitly and the module that should get the information injected must define its input explicitly.

This means that if we’d need security group information to configure our AWS instances, that instance must be able to get information about those security groups (which are located in a different module). To get this to work, the security-groups module must output this information to its parent (the vpc module). From this vpc module, that information must be output back to the main file where it can be injected into the ec2 module which in its turn can inject it into the instances module.

A visual representation of this looks like the below image:

Since we’ll be working with quite a lot of modules, we’ll often output more than just the required information back to the main module. This isn’t required but provides flexibility and reduces the changes that need to be made when the infrastructure has to change.

Creating our directory structure

As we’ll be needing all of the resources described in the architecture plus some extra ones, we can start by creating a large number of directories to logically structure our modules:

.
└── modules
    ├── ec2
    │   └── modules
    │       ├── images
    │       └── instances
    └── vpc
        └── modules
            ├── elastic-ips
            ├── internet-gateways
            ├── nat-gateways
            ├── route-table-associations
            ├── routing-tables
            ├── security-groups
            ├── subnets
            └── vpcs

Luckily we can use `mkdir -p` to more easily create this structure:

mkdir -p modules/ec2/modules/images
mkdir -p modules/ec2/modules/instances
mkdir -p modules/vpc/modules/elastic-ips
mkdir -p modules/vpc/modules/internet-gateways
mkdir -p modules/vpc/modules/nat-gateways
mkdir -p modules/vpc/modules/route-table-associations
mkdir -p modules/vpc/modules/routing-tables
mkdir -p modules/vpc/modules/security-groups
mkdir -p modules/vpc/modules/subnets
mkdir -p modules/vpc/modules/vpcs

As mentioned before, the Terraform documentation suggests creating modules with 3 files:

main.tf containing the core of the module
variables.tf containing the input variables this module expects
outputs.tf containing the variables this module exports

We can do this dynamically using the find command

find modules -type d -maxdepth 3 -mindepth 3 -exec touch {}/main.tf {}/variables.tf {}/outputs.tf \;

The above will find all directories 3 deep starting from the modules directory and create the 3 required files. We need to do the same thing so that our our vpc and ec2 directories also contain these 3 files:

find modules -type d -maxdepth 1 -mindepth 1 -exec touch {}/main.tf {}/variables.tf {}/outputs.tf \;

Lastly, our root directory also needs the 3 files:

touch main.tf variables.tf outputs.tf

We end up with this structure:

.
├── main.tf
├── modules
│   ├── ec2
│   │   ├── main.tf
│   │   ├── modules
│   │   │   ├── images
│   │   │   │   ├── main.tf
│   │   │   │   ├── outputs.tf
│   │   │   │   └── variables.tf
│   │   │   └── instances
│   │   │       ├── main.tf
│   │   │       ├── outputs.tf
│   │   │       └── variables.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   └── vpc
│       ├── main.tf
│       ├── modules
│       │   ├── elastic-ips
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   ├── internet-gateways
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   ├── nat-gateways
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   ├── route-table-associations
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   ├── routing-tables
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   ├── security-groups
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   ├── subnets
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   └── vpcs
│       │       ├── main.tf
│       │       ├── outputs.tf
│       │       └── variables.tf
│       ├── outputs.tf
│       └── variables.tf
├── outputs.tf
└── variables.tf

15 directories, 39 files

Root default tags

AWS allows you to add tags to most resources. I decided it would be easy (although it’s optional) to have an Owner tag with the value Terraform on each resource. As this is something that should be used by all different modules, we can define it in our root module within the variables.tf file:

variable "default_tags" {
  default = {
    "Owner" : "Terraform"
  }
  description = "Default resource tags to be used across all resources"
  type        = map(string)
}

We’ll later implement these default tags in our actual resources but keep in mind that if we want to be able to access this variable, we need to explicitly pass the data through all modules!

Order or defining modules & resources

We’ll be creating our configuration files in a very specific order so that all dependencies are always available to the module we’re creating. There are dependencies between resources that must be met before the configuration can work.

The order of defining modules (and thus resources) used within this post is:

  • Main module
  • vpc module
    • vpcs module
    • subnets  module
    • security-groups module
    • internet-gateways module
  • ec2 module
    • images module
    • instances module
  • vpc module
    • elastic-ips module
    • nat-gateways module
    • routing-tables module
    • route-table-associations module

Provider & 1st level module (main)

Now that we have our main structure and our default tags set up, we can start by filling in our configuration files. We’ll start with specifying our provider which is AWS and defining one of the modules for which we created the files. The main.tf file in the root of our project will only include the ec2 and vpc modules. These 2 modules will then include their required modules but we’ll get to that later.

As we’ll have to work on the networking first, it’s logical that we start with the vpc module.

Open up the main.tf file in the root of the project and define the:

  • AWS provider
  • VPC module

We’ll already go ahead and pass the default_tags variable that we created to each module so that we can use it later on.

# Configure the AWS Provider
provider "aws" {
  version = "~> 2.0"
}

# Import VPC module
module "vpc" {
  source = "./modules/vpc"

  # Pass default_tags
  default_tags = var.default_tags
}

Variables are simply passed within the module definition and we can reference the variable we created in the variable.tf file.

2nd level modules: vpc

Now that our main file includes the vpc module, we can start building out that module. Our main module includes the modules/vpc directory meaning it will take all of the files in that directory into account. As we passed a variable called default_tags, we need to explicitly define it in the modules/vpc/variables.tf.

Open up the modules/vpc/variables.tf file and add the expected variable definition like so:

variable "default_tags" {}

Now that our module expects the variable, we can start working on its nested modules. Let’s open up our modules/vpc/main.tf file and configure it to include a sub-module (the 3rd level module) called vpcs:

# Import vpcs module
module "vpcs" {
  source = "./modules/vpcs"

  # Pass default_tags
  default_tags = var.default_tags
}

3rd level modules: vpc -> vpcs

The first thing we should define is our VPC. Almost everything else will build on this VPC so it’s logical to start here.
As before, the vpcs module expects the default_tags variable so we need to define it in the modules/vpc/modules/vpcs/variables.tf file:

variable "default_tags" {}

Within the AWS provider, we can define a VPC using the aws_vpc resource. The VPC doesn’t take too many parameters so it should be easy to set up. The most important one for us is the cidr_block, the value of which was specified in the architecture diagram (10.0.0.0/16).

These resources should be defined in the main.tf file of the correct module. In our case that’s modules/vpc/modules/vpcs/main.tf:

# Create a VPC
resource "aws_vpc" "terraform-vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = merge(
    var.default_tags,
    {
      Name = "terraform-vpc"
    }
  )
}

Notice the tags section. There’s a large list of functions that can be used in order to make our life easier, like the merge function we’re using here to combine our default tags (Owner: Terraform) with the tags we define on the resource itself.

Running the terraform command for the first time

Now that we have a VPC defined, we can try running the terraform command to see if is able to parse everything we’ve created. We can see what terraform would do by running terraform plan. However, if we do that we’ll get an error:

$ terraform plan

Error: Module not installed

  on main.tf line 7:
   7: module "vpc" {

This module is not yet installed. Run "terraform init" to install all modules
required by this configuration.

Terraform doesn’t know about the modules, for that we need to run terraform init first. You’ll have to do this each time you add a new module.

$ terraform init
Initializing modules...
- vpc in modules/vpc
- vpc.vpcs in modules/vpc/modules/vpcs

Initializing the backend...

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "aws" (hashicorp/aws) 2.68.0...

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.

We’re now ready to try and see what terraform plan gives us.

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.


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

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:

  # module.vpc.module.vpcs.aws_vpc.terraform-vpc will be created
  + resource "aws_vpc" "terraform-vpc" {
      + arn                              = (known after apply)
      + assign_generated_ipv6_cidr_block = false
      + cidr_block                       = "10.0.0.0/16"
      + default_network_acl_id           = (known after apply)
      + default_route_table_id           = (known after apply)
      + default_security_group_id        = (known after apply)
      + dhcp_options_id                  = (known after apply)
      + enable_classiclink               = (known after apply)
      + enable_classiclink_dns_support   = (known after apply)
      + enable_dns_hostnames             = true
      + enable_dns_support               = true
      + id                               = (known after apply)
      + instance_tenancy                 = "default"
      + ipv6_association_id              = (known after apply)
      + ipv6_cidr_block                  = (known after apply)
      + main_route_table_id              = (known after apply)
      + owner_id                         = (known after apply)
      + tags                             = {
          + "Name"  = "terraform-vpc"
          + "Owner" = "Terraform"
        }
    }

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

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

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.

Perfect! Terraform says it’d create our VPC if we were to run terraform apply. We’ll be using the terraform plan command quite a few times to see if we defined everything correctly and if Terraform picks up our configuration completely.

Next up: subnets!

3rd level modules: vpc -> subnets

We’ve now arrived on the first resource we want to define that requires some information from another module. A subnet is defined within a VPC so we’ll require some information from the VPC we created before. We’ll start by defining our first output, as the VPC is defined in the vpcs module, we’ll have to output some information in that module to its parent module vpc.

Open up the modules/vpc/modules/vpcs/outputs.tf file and paste in the following content:

output "vpcs" {
  value = {
    "terraform-vpc" : aws_vpc.terraform-vpc
  }
}

What this will do is output a variable called `vpcs` which will contain terraform-vpc. Notice that we’re referencing the aws_vpc.terraform-vpc. This is possible because it was defined in this module. The resulting terraform-vpc will contain a lot of information regarding the VPC.

Now, in theory, we could use the output from the vpcs module and inject it into the subnets that we’ll soon create. However, we’ll be needing the vpcs outputs for some other resources so I opted to pass all data through to the main module and pass it back down along to the sub modules. Let’s try and do that.

Open the modules/vpc/outputs.tf file and add the following:

output "vpcs" {
  value = module.vpcs.vpcs
}

This will output the vpcs we output in the vpcs module in the vpc module too. The value we’re using references the child module that’s defined (vpcs) and then the name of the output that’s defined in that child module (also vpcs).

Once we have this in place, our main module can actually start using the data output by the vpc module. As the subnets module is a child module of vpc, we will be able to pass this data along. Let’s start from the top.

Open the main.tf file and add a vpcs variable to it (beneath the passing of the default tags):

# Import VPC module
module "vpc" {
  source = "./modules/vpc"

  # Pass default_tags
  default_tags = var.default_tags

  # Pass VPC variables                          <-- This and the line below is new
  vpcs                     = module.vpc.vpcs
}

This will pass a variable called vpcs to the vpc module. If we add a new input variable, we also need to define it on the module. Open the modules/vpc/variables.tf file and add this new vpcs variable so that the file looks like:

variable "default_tags" {}
variable "vpcs" {}

Awesome. Our vpc module now has access to that variable and can use it for our subnets module.

Open up the modules/vpc/modules/subnets/variables.tf file so that we can specify the vpcs along with default_tags as input variables:

variable "default_tags" {}
variable "vpcs" {}

Now that we have the input variables for the subnets module defined we can include is in our vpc module (subnets is a child module of vpc). Open the modules/vpc/main.tf file and append the new module including its variable:

# Import subnets module
module "subnets" {
  source = "./modules/subnets"

  # Pass default_tags
  default_tags = var.default_tags

  vpcs = var.vpcs
}

Remember, we wanted to define 2 subnets:

  • Private subnet with CIDR 10.0.0.0/24
  • Public subnet with CIDR 10.0.1.0/24

Open up the modules/vpc/modules/subnets/main.tf file so that we can define these 2 subnets:

# Define the private subnet
resource "aws_subnet" "terraform-private-subnet" {
  vpc_id     = var.vpcs.terraform-vpc.id
  cidr_block = "10.0.0.0/24"

  tags = merge(
    var.default_tags,
    {
      Name = "Terraform private subnet"
    }
  )
}

# Define the public subnet
resource "aws_subnet" "terraform-public-subnet" {
  vpc_id     = var.vpcs.terraform-vpc.id
  cidr_block = "10.0.1.0/24"

  tags = merge(
    var.default_tags,
    {
      Name = "Terraform public subnet"
    }
  )
}

Notice that we can now use the var notation to access variables that were passed along. In our case we’re accessing the terraform-vpc from the vpcs variable. On that object we grab the id. The id field from our VPC is automatically filled in by Terraform after the VPC is created. Terraform even knows that these may not already be available and it will execute your config files in order so that all variables are available when required. Smart!

Let’s also output the subnet information by adding the following to the modules/vpc/modules/subnets/outputs.tf file:

output "subnets" {
  value = {
    "terraform-private-subnet" : aws_subnet.terraform-private-subnet
    "terraform-public-subnet" : aws_subnet.terraform-public-subnet
  }
}

And then output this back up to the main file by appending the following to the modules/vpc/outputs.tf file:

output "subnets" {
  value = module.subnets.subnets
}

Running the terraform plan

If you try to run terraform plan again, it fails because of missing modules. Remember that you have to run terraform init each time you add a new module. The output will also show that it picked up the new module.

$ terraform init
Initializing modules...
- vpc.subnets in modules/vpc/modules/subnets
$ terraform plan
Terraform will perform the following actions:

  # module.vpc.module.subnets.aws_subnet.terraform-private-subnet will be created
  + resource "aws_subnet" "terraform-private-subnet" {
      + arn                             = (known after apply)
      + assign_ipv6_address_on_creation = false
      + availability_zone               = (known after apply)
      + availability_zone_id            = (known after apply)
      + cidr_block                      = "10.0.0.0/24"
      + id                              = (known after apply)
      + ipv6_cidr_block                 = (known after apply)
      + ipv6_cidr_block_association_id  = (known after apply)
      + map_public_ip_on_launch         = false
      + owner_id                        = (known after apply)
      + tags                            = {
          + "Name"  = "Terraform private subnet"
          + "Owner" = "Terraform"
        }
      + vpc_id                          = (known after apply)
    }

  # module.vpc.module.subnets.aws_subnet.terraform-public-subnet will be created
  + resource "aws_subnet" "terraform-public-subnet" {
      + arn                             = (known after apply)
      + assign_ipv6_address_on_creation = false
      + availability_zone               = (known after apply)
      + availability_zone_id            = (known after apply)
      + cidr_block                      = "10.0.1.0/24"
      + id                              = (known after apply)
      + ipv6_cidr_block                 = (known after apply)
      + ipv6_cidr_block_association_id  = (known after apply)
      + map_public_ip_on_launch         = false
      + owner_id                        = (known after apply)
      + tags                            = {
          + "Name"  = "Terraform public subnet"
          + "Owner" = "Terraform"
        }
      + vpc_id                          = (known after apply)
    }

On top of the subnet it still needs to create, it also added our 2 subnets!

3rd level modules: vpc -> security-groups

Before we start adding EC2 instances, we’ll set up our security groups. As mentioned before, we wanted 3 main policies:

As we now have the VPC and subnets in place, we can start defining these. These policies need information about the VPC, which we already have!

Open the modules/vpc/main.tf file and add the new security-groups module including the vpcs variable and the default tags:

# Import security-groups module
module "security-groups" {
  source = "./modules/security-groups"

  # Pass default_tags
  default_tags = var.default_tags

  vpcs = var.vpcs
}

Now make sure the security-groups module expects these 2 variables as input by defining them in modules/vpc/modules/security-groups/variables.tf:

variable "default_tags" {}
variable "vpcs" {}

Define the security groups in the modules/vpc/modules/security-groups/main.tf file:

resource "aws_security_group" "allow-external-ssh" {
  name        = "allow-external-ssh"
  description = "Allow external SSH inbound traffic"
  vpc_id      = var.vpcs.terraform-vpc.id

  ingress {
    description = "SSH from outside the network"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(
    var.default_tags,
    {
      Name = "Allow external SSH inbound traffic"
    }
  )
}

resource "aws_security_group" "allow-internal-ssh" {
  name        = "allow-internal-ssh"
  description = "Allow internal SSH inbound traffic"
  vpc_id      = var.vpcs.terraform-vpc.id

  ingress {
    description = "SSH from inside the network"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.vpcs.terraform-vpc.cidr_block]
  }

  tags = merge(
    var.default_tags,
    {
      Name = "Allow internal SSH inbound traffic"
    }
  )
}

resource "aws_security_group" "allow-all-outbound-traffic" {
  name        = "allow-all-outbound-traffic"
  description = "Allow all outbound traffic"
  vpc_id      = var.vpcs.terraform-vpc.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(
    var.default_tags,
    {
      Name = "Allow all outbound traffic"
    }
  )
}
    

Notice that we can’t only use the id on previously defined resources but also other parameters such as the cidr_block of our VPC.

Now that we defined these, let’s output them as we’ll need them later. Open the modules/vpc/modules/security-groups/outputs.tf file and paste the following content in it:

output "security-groups" {
  value = {
    "allow-external-ssh" : aws_security_group.allow-external-ssh
    "allow-internal-ssh" : aws_security_group.allow-internal-ssh
    "allow-all-outbound-traffic" : aws_security_group.allow-all-outbound-traffic
  }
}

Let’s also already output these security-groups back up to the main module by appending the following to the modules/vpc/outputs.tf file:

output "security-groups" {
  value = module.security-groups.security-groups
}

If you’d like you can run terraform plan again to see the changes.

3rd level modules: vpc -> internet-gateways

Hopefully defining these modules becomes somewhat familiar by now. Internet gateways is next up!

Open the modules/vpc/main.tf file and append another module:

# Import internet-gateways module
module "internet-gateways" {
  source = "./modules/internet-gateways"

  # Pass default_tags
  default_tags = var.default_tags

  vpcs = var.vpcs
}

Add the expected variables to the modules/vpc/modules/internet-gateways/variables.tf file:

variable "default_tags" {}
variable "vpcs" {}

Define the internet gateway in modules/vpc/modules/internet-gateways/main.tf:

# Define the internet gateway
resource "aws_internet_gateway" "terraform-gateway" {
  vpc_id = var.vpcs.terraform-vpc.id
  tags = merge(
    var.default_tags,
    {
      Name = "Terraform Internet gateway"
    }
  )
}

And then output it back to the vpc module by modifying the modules/vpc/modules/internet-gateways/outputs.tf file:

output "internet-gateways" {
  value = {
    "terraform-gateway" : aws_internet_gateway.terraform-gateway
  }
}

And lastly outputting it back to the main module by appending the following to the modules/vpc/outputs.tf file:

output "internet-gateways" {
  value = module.internet-gateways.internet-gateways
}

Run terraform plan if you’d like.

2nd level modules: ec2

We can start working on our EC2 module, defining images and instances. Let’s start off by defining the ec2 module in our main.tf file by appending:

# Import EC2 module
module "ec2" {
  source = "./modules/ec2"

  # Pass default_tags
  default_tags = var.default_tags
  # Pass VPC variables
  security-groups          = module.vpc.security-groups
  subnets                  = module.vpc.subnets
  # Pass EC2 variables
  images                   = module.ec2.images
}

The EC2 module will need the subnets, security-groups, images and default_tags variables in order to define the EC2 instances. We don’t yet have the images variable but we’ll define it in the next step.

These have to be defined in the modules/ec2/variables.tf file like so:

variable "default_tags" {}
variable "security-groups" {}
variable "subnets" {}
variable "images" {}

3rd level modules: ec2 -> images

As a last step before we can start defining our instances, we need images. AWS requires you to pick an OS image when defining an EC2 instance. These images are a bit different when compared to other resources as we won’t be creating images. We’ll want to pick an existing image from the AWS marketplace.

Let’s assign the images module as a child module of ec2 by adding the following to the modules/ec2/main.tf file:

# Import images module
module "images" {
  source = "./modules/images"
}

The images module requires no variables as input.

Then, open up the modules/ec2/modules/images/main.tf file and add the following:

# Define 'amazon-linux-2' AMI
data "aws_ami" "amazon-linux-2" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm*"]
  }
}

What this will do is search for an image on the AWS marketplace with the name starting with amzn2-ami-hvm and the owner being amazon. The resulting image will be the amazon-linux-2 image that we’ll use for our instances.

As our instances needs this image information, we need to output it by modifying the modules/ec2/modules/images/outputs.tf file:

output "images" {
  value = {
    "amazon-linux-2" : data.aws_ami.amazon-linux-2
  }
}

And then output it again back to the main file as usual by modifying the modules/ec2/outputs.tf file:

output "images" {
  value = module.images.images
}

3rd level modules: ec2 -> instances

We’ll be adding our instances now. If you go back up to the diagram, we wanted 2 instances. We’ll pick a t2.micro size for both.

Modify the modules/ec2/main.tf file to add the new instances module:

# Import instances module
module "instances" {
  source = "./modules/instances"

  # Pass default_tags
  default_tags = var.default_tags

  subnets         = var.subnets
  images          = var.images
  security-groups = var.security-groups
}

We’ll define the 4 expected inputs for the instances module by having the following in the modules/ec2/modules/instances/variables.tf file:

variable "default_tags" {}
variable "subnets" {}
variable "images" {}
variable "security-groups" {}

Now we can define our instances by filling in the modules/ec2/modules/instances/main.tf file. We’re defining in which subnet these machines should be located, which image they should use and which security groups they must use. The Bastion instance is also configured to receive a public IP address with the associate_public_ip_address parameter.

# Define the bastion instance
resource "aws_instance" "bastion" {
  ami                         = var.images.amazon-linux-2.id
  instance_type               = "t2.micro"
  associate_public_ip_address = true
  subnet_id                   = var.subnets.terraform-public-subnet.id
  private_ip                  = "10.0.1.10"
  key_name                    = "Admin-keypair"
  vpc_security_group_ids = [
    var.security-groups.allow-all-outbound-traffic.id,
    var.security-groups.allow-external-ssh.id
  ]
  tags = merge(
    var.default_tags,
    {
      Name = "Bastion"
    }
  )
}

# Define the acceptance instance
resource "aws_instance" "acceptance" {
  ami                         = var.images.amazon-linux-2.id
  instance_type               = "t2.micro"
  associate_public_ip_address = false
  subnet_id                   = var.subnets.terraform-private-subnet.id
  private_ip                  = "10.0.0.10"
  key_name                    = "Admin-keypair"
  vpc_security_group_ids = [
    var.security-groups.allow-all-outbound-traffic.id,
    var.security-groups.allow-internal-ssh.id
  ]
  tags = merge(
    var.default_tags,
    {
      Name = "Acceptance"
    }
  )
}

Important information regarding SSH access: There’s a key_name defined in the modules/ec2/modules/instances/main.tf file. This key_name points to a key I’ve uploaded in the AWS console manually but this can also be automated using the Terraform aws_key_pair resource. If you don’t specify a key_name you won’t be able to SSH into either instances.

We still need to output the 2 instances by modifying modules/ec2/modules/instances/outputs.tf:

output "instances" {
  value = {
    "bastion" : aws_instance.bastion
    "acceptance" : aws_instance.acceptance
  }
}

And also appending the following to modules/ec2/outputs.tf to output the instances back to the main file:

output "instances" {
  value = module.instances.instances
}

Running the terraform plan

If you try to run terraform plan again, it fails because of missing modules. Remember that you have to run terraform init each time you add a new module. The output will also show that it picked up the new modules.

terraform plan will now show everything we’ve created so far:

  • 1 VPC
  • 2 subnets
  • 2 instances
  • 1 internet gateway
  • 3 security groups

Status thus far

We’ve created quite a lot already but there’s a few things still missing, namely:

  • A NAT gateway for our private VPC instance to be able to reach the outside world
  • An elastic IP that should be assigned to our NAT gateway for it to work
  • Routing tables & their associations so that the subnets know where to send traffic to

3rd level modules: vpc -> elastic-ips

We’re only now defining these elastic IPs as we wanted an elastic IP on both our Bastion server as well as the NAT gateway. When defining the elastic IP for Bastion, we must provide Terraform with the Bastion instance ID.

These elastic IPs are straightforward to set up. Start by passing the instance information from the root main.tf file to the vpc module:

# Import VPC module
module "vpc" {
  source = "./modules/vpc"

  # Pass default_tags
  default_tags = var.default_tags

  # Pass VPC variables
  vpcs                     = module.vpc.vpcs
  internet-gateways        = module.vpc.internet-gateways        # <-- Everything from this point is new
  # Pass EC2 variables
  instances                = module.ec2.instances
}

The vpc module does not expect the instances or internet-gateways variable yet so let’s fix that by appending it to the modules/vpc/variables.tf file:

variable "instances" {}
variable "internet-gateways" {}

Then, register the module in the modules/vpc/main.tf file by appending:

# Import elastic-ips module
module "elastic-ips" {
  source = "./modules/elastic-ips"

  # Pass default_tags
  default_tags = var.default_tags

  instances         = var.instances
  internet-gateways = var.internet-gateways
}

Make sure the elastic-ips module expects the variables by specifying them in modules/vpc/modules/elastic-ips/variables.tf:

variable "default_tags" {}
variable "instances" {}
variable "internet-gateways" {}

Once this is in place we can define the elastic IPs in modules/vpc/modules/elastic-ips/main.tf:

# Define an elastic ip for the bastion host
resource "aws_eip" "bastion-elastic-ip" {
  instance                  = var.instances.bastion.id
  vpc                       = true
  associate_with_private_ip = var.instances.bastion.private_ip
  tags = merge(
    var.default_tags,
    {
      Name = "Bastion Elastic IP"
    }
  )
}

# Define an elastic ip for the bastion host
resource "aws_eip" "terraform-nat-gateway-elastic-ip" {
  vpc = true
  tags = merge(
    var.default_tags,
    {
      Name = "Terraform NAT gateway Elastic IP"
    }
  )
}

We’ll output these by appending the following to the modules/vpc/modules/elastic-ips/outputs.tf file:

output "elastic-ips" {
  value = {
    "bastion-elastic-ip" : aws_eip.bastion-elastic-ip
    "terraform-nat-gateway-elastic-ip" : aws_eip.terraform-nat-gateway-elastic-ip
  }
}

And outputting it back up to the main file for good measure by appending the following to modules/vpc/outputs.tf:

output "elastic-ips" {
  value = module.elastic-ips.elastic-ips
}

3rd level modules: vpc -> nat-gateways

Now that our elastic IP is in place we can define our NAT gateway (it requires an elastic IP). We’ll need the elastic IP variables and the subnet information, though. So we’ll start by passing those to the vpc module in our main.tf file:

module "vpc" {
  source = "./modules/vpc"

  # Pass default_tags
  default_tags = var.default_tags

  # Pass VPC variables
  vpcs                     = module.vpc.vpcs
  internet-gateways        = module.vpc.internet-gateways
  elastic-ips              = module.vpc.elastic-ips       # <-- This is new
  subnets                  = module.vpc.subnets           # <-- This is new
  # Pass EC2 variables
  instances                = module.ec2.instances
}

We once again have to modify our modules/vpc/variables.tf file in order to be able to pass this variable:

variable "default_tags" {}
variable "vpcs" {}
variable "instances" {}
variable "internet-gateways" {}
variable "elastic-ips" {}             # <-- This is new
variable "subnets" {}                 # <-- This is new

Now we need to define our nat-gateways module within the modules/vpc/main.tf file:

# Import nat-gateways module
module "nat-gateways" {
  source = "./modules/nat-gateways"

  # Pass default_tags
  default_tags = var.default_tags

  subnets           = var.subnets
  elastic-ips       = var.elastic-ips
  internet-gateways = var.internet-gateways
}

As usual, the default_tags along with all other variables need to be defined in the modules/vpc/modules/nat-gateways/variables.tf file:

variable "default_tags" {}
variable "subnets" {}
variable "elastic-ips" {}
variable "internet-gateways" {}

And now we can specify the gateway itself by filling in the modules/vpc/modules/nat-gateways/main.tf file:

resource "aws_nat_gateway" "terraform-gateway" {
  allocation_id = var.elastic-ips.terraform-nat-gateway-elastic-ip.id
  subnet_id     = var.subnets.terraform-public-subnet.id
  tags = merge(
    var.default_tags,
    {
      Name = "Terraform NAT gateway"
    }
  )
}

The NAT gateway should be output as it’ll be required for the next part. Copy the following into the modules/vpc/modules/nat-gateways/outputs.tf file:

output "nat-gateways" {
  value = {
    "terraform-gateway" : aws_nat_gateway.terraform-gateway
  }
}

Output it back up to the main module by appending the following to the modules/vpc/outputs.tf file:

output "nat-gateways" {
  value = module.nat-gateways.nat-gateways
}

3rd level modules: vpc -> routing-tables

The routing tables are necessary for our subnets to know how to route traffic. Internal traffic is automatically routed but traffic meant for the outside world must be configured. We already have our 2 subnets including our 2 instances, a NAT gateway and an internet gateway. The NAT gateway will be used for traffic coming from our private subnet, the internet gateway will be used for traffic coming from the public subnet.

Setting up these routing tables requires information about the gateways (so both NAT & internet) and the VPC.

First let’s pass the NAT gateway data to our vpc module by (once again) modifying the main.tf file:

# Import VPC module
module "vpc" {
  source = "./modules/vpc"

  # Pass default_tags
  default_tags = var.default_tags

  # Pass VPC variables
  vpcs                     = module.vpc.vpcs
  internet-gateways        = module.vpc.internet-gateways
  elastic-ips              = module.vpc.elastic-ips
  subnets                  = module.vpc.subnets
  nat-gateways             = module.vpc.nat-gateways                 # <-- This is new
  # Pass EC2 variables
  instances                = module.ec2.instances
}

It must be specified in the modules/vpc/variables.tf file:

variable "default_tags" {}
variable "vpcs" {}
variable "instances" {}
variable "internet-gateways" {}
variable "elastic-ips" {}
variable "subnets" {}
variable "nat-gateways" {}                 # <-- This is new

And then we can define our new `routing-tables` module by appending it to the modules/vpc/main.tf file:

# Import routing-tables module
module "routing-tables" {
  source = "./modules/routing-tables"

  # Pass default_tags
  default_tags = var.default_tags

  vpcs              = var.vpcs
  internet-gateways = var.internet-gateways
  nat-gateways      = var.nat-gateways
}

Let’s make this new module expect all 4 parameters (default_tags, vpcs, internet-gateways & nat-gateways) by changing modules/vpc/modules/routing-tables/variables.tf:

variable "default_tags" {}
variable "vpcs" {}
variable "internet-gateways" {}
variable "nat-gateways" {}

And then defining the routes in modules/vpc/modules/routing-tables/main.tf:

# Set up the private subnet routing table
resource "aws_route_table" "terraform-private-routing-table" {
  vpc_id = var.vpcs.terraform-vpc.id
  route {
    cidr_block = "0.0.0.0/0"
    nat_gateway_id = var.nat-gateways.terraform-gateway.id
  }
  tags = merge(
    var.default_tags,
    {
      Name = "Terraform private subnet routing table"
    }
  )
}

# Set up the public subnet routing table
resource "aws_route_table" "terraform-public-routing-table" {
  vpc_id = var.vpcs.terraform-vpc.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = var.internet-gateways.terraform-gateway.id
  }
  tags = merge(
    var.default_tags,
    {
      Name = "Terraform public subnet routing table"
    }
  )
}

Notice that we’re setting up 2 routing tables. This is for later use within the 2 different subnets. Also note that we only have to route the CIDR block for internet access (0.0.0.0/0) and that we do so depending on which subnet we’ll be using the route on.

These routing tables on their own have no use. We’ll need to associate these with our subnets in the following (and last) step.

We can output these again by modifying the modules/vpc/modules/routing-tables/outputs.tf file:

output "routing-tables" {
  value = {
    "terraform-private-routing-table" : aws_route_table.terraform-private-routing-table
    "terraform-public-routing-table" : aws_route_table.terraform-public-routing-table
  }
}

And then back to the main module by appending to the modules/vpc/outputs.tf file:

3rd level modules: vpc -> route-table-associations

The route table associations are what… associate the routing tables of course! It allows you to associate these tables with an existing subnet so that’s exactly what we’ll do. If you made it this far: congratulations, you’re amazing! It’s the last step before we can apply this and test it out.

Start by passing the routing-tables with the vpc module in the main.tf file:

# Import VPC module
module "vpc" {
  source = "./modules/vpc"

  # Pass default_tags
  default_tags = var.default_tags

  # Pass VPC variables
  vpcs                     = module.vpc.vpcs
  internet-gateways        = module.vpc.internet-gateways
  elastic-ips              = module.vpc.elastic-ips
  subnets                  = module.vpc.subnets
  nat-gateways             = module.vpc.nat-gateways
  routing-tables           = module.vpc.routing-tables                 # <-- This is new
  # Pass EC2 variables
  instances                = module.ec2.instances
}

Make the vpc module expect is as an input by appending to modules/vpc/variables.tf:

variable "default_tags" {}
variable "vpcs" {}
variable "instances" {}
variable "internet-gateways" {}
variable "elastic-ips" {}
variable "subnets" {}
variable "nat-gateways" {}
variable "routing-tables" {}                 # <-- This is new

And define the new route-table-associations in modules/vpc/main.tf:

# Import VPC module
module "vpc" {
  source = "./modules/vpc"

  # Pass default_tags
  default_tags = var.default_tags

  # Pass VPC variables
  vpcs                     = module.vpc.vpcs
  internet-gateways        = module.vpc.internet-gateways
  elastic-ips              = module.vpc.elastic-ips
  subnets                  = module.vpc.subnets
  nat-gateways             = module.vpc.nat-gateways
  routing-tables           = module.vpc.routing-tables                 # <-- This is new
  # Pass EC2 variables
  instances                = module.ec2.instances
}

Make the vpc module expect is as an input by appending to modules/vpc/variables.tf:

variable "default_tags" {}
variable "vpcs" {}
variable "instances" {}
variable "internet-gateways" {}
variable "elastic-ips" {}
variable "subnets" {}
variable "nat-gateways" {}
variable "routing-tables" {}                 # <-- This is new

And define the new route-table-associations in modules/vpc/main.tf:

# Import route-table-associations module
module "route-table-associations" {
  source = "./modules/route-table-associations"

  # Pass default_tags
  default_tags = var.default_tags

  subnets        = var.subnets
  routing-tables = var.routing-tables
}

We need to define the inputs for this new module in modules/vpc/modules/route-table-associations/variables.tf:

variable "default_tags" {}
variable "subnets" {}
variable "routing-tables" {}

Now we can define the route table associations in modules/vpc/modules/route-table-associations/main.tf:

# Associate the private subnet with the routing table
resource "aws_route_table_association" "terraform-private-route-table-association" {
  subnet_id      = var.subnets.terraform-private-subnet.id
  route_table_id = var.routing-tables.terraform-private-routing-table.id
}

# Associate the public subnet with the routing table
resource "aws_route_table_association" "terraform-public-route-table-association" {
  subnet_id      = var.subnets.terraform-public-subnet.id
  route_table_id = var.routing-tables.terraform-public-routing-table.id
}

And I won’t bother outputting these as we won’t need to reference these route-table-associations anywhere else.

Running terraform apply

If we now run terraform init and terraform plan we should see Terraform wants to add 16 resources.

If we then run terraform apply, it will deploy this entire infrastructure to AWS:

$ terraform apply -auto-approve
module.ec2.module.images.data.aws_ami.amazon-linux-2: Refreshing state...
module.vpc.module.elastic-ips.aws_eip.terraform-nat-gateway-elastic-ip: Creating...
module.vpc.module.vpcs.aws_vpc.terraform-vpc: Creating...
module.vpc.module.elastic-ips.aws_eip.terraform-nat-gateway-elastic-ip: Creation complete after 1s [id=eipalloc-0bd9faa5d9367efce]
module.vpc.module.vpcs.aws_vpc.terraform-vpc: Creation complete after 3s [id=vpc-03bc4fa4ac1ed7969]
module.vpc.module.internet-gateways.aws_internet_gateway.terraform-gateway: Creating...
module.vpc.module.subnets.aws_subnet.terraform-private-subnet: Creating...
module.vpc.module.subnets.aws_subnet.terraform-public-subnet: Creating...
module.vpc.module.security-groups.aws_security_group.allow-external-ssh: Creating...
module.vpc.module.security-groups.aws_security_group.allow-internal-ssh: Creating...
module.vpc.module.security-groups.aws_security_group.allow-all-outbound-traffic: Creating...
module.vpc.module.subnets.aws_subnet.terraform-private-subnet: Creation complete after 1s [id=subnet-0c53c99d57edac536]
module.vpc.module.subnets.aws_subnet.terraform-public-subnet: Creation complete after 1s [id=subnet-0b2ac820158e3a859]
module.vpc.module.internet-gateways.aws_internet_gateway.terraform-gateway: Creation complete after 2s [id=igw-099e61afd119f40be]
module.vpc.module.routing-tables.aws_route_table.terraform-public-routing-table: Creating...
module.vpc.module.security-groups.aws_security_group.allow-all-outbound-traffic: Creation complete after 2s [id=sg-0fb61cb1f99b94f2a]
module.vpc.module.security-groups.aws_security_group.allow-external-ssh: Creation complete after 2s [id=sg-0585603dbc3a9b586]
module.vpc.module.security-groups.aws_security_group.allow-internal-ssh: Creation complete after 2s [id=sg-0e5ae44dedd3daa0a]
module.ec2.module.instances.aws_instance.bastion: Creating...
module.ec2.module.instances.aws_instance.acceptance: Creating...
module.vpc.module.routing-tables.aws_route_table.terraform-public-routing-table: Creation complete after 1s [id=rtb-01b631235fb1dcceb]
module.ec2.module.instances.aws_instance.bastion: Still creating... [10s elapsed]
module.ec2.module.instances.aws_instance.acceptance: Still creating... [10s elapsed]
module.ec2.module.instances.aws_instance.acceptance: Still creating... [20s elapsed]
module.ec2.module.instances.aws_instance.bastion: Still creating... [20s elapsed]
module.ec2.module.instances.aws_instance.acceptance: Creation complete after 23s [id=i-04734bb86458c731f]
module.ec2.module.instances.aws_instance.bastion: Creation complete after 23s [id=i-0fb21c2409c7c44e0]
module.vpc.module.elastic-ips.aws_eip.bastion-elastic-ip: Creating...
module.vpc.module.elastic-ips.aws_eip.bastion-elastic-ip: Creation complete after 1s [id=eipalloc-03ec6710a634b6b3f]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Creating...
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [10s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [20s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [30s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [40s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [50s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [1m0s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [1m10s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [1m20s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Still creating... [1m30s elapsed]
module.vpc.module.nat-gateways.aws_nat_gateway.terraform-gateway: Creation complete after 1m36s [id=nat-0630f741fbd30622a]
module.vpc.module.routing-tables.aws_route_table.terraform-private-routing-table: Creating...
module.vpc.module.routing-tables.aws_route_table.terraform-private-routing-table: Creation complete after 2s [id=rtb-002415be059defa3f]
module.vpc.module.route-table-associations.aws_route_table_association.terraform-private-route-table-association: Creating...
module.vpc.module.route-table-associations.aws_route_table_association.terraform-public-route-table-association: Creating...
module.vpc.module.route-table-associations.aws_route_table_association.terraform-public-route-table-association: Creation complete after 0s [id=rtbassoc-03e7e5d02bc1e1d9b]
module.vpc.module.route-table-associations.aws_route_table_association.terraform-private-route-table-association: Creation complete after 0s [id=rtbassoc-0aa71774d51d90002]

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

Now the EC2 instances are visible from within the AWS console:

Using the public IP of the Bastion host (3.126.162.94) I can set up my .ssh/config as follows:

Host bastion
    HostName 3.126.162.94
    User ec2-user
    IdentityFile ~/.ssh/Dots-Admin-keypair.pem
Host acceptance
    HostName 10.0.0.10
    User ec2-user
    IdentityFile ~/.ssh/Dots-Admin-keypair.pem
    ProxyJump bastion

I can then SSH into Bastion and ping Google:

$ ssh bastion
[ec2-user@ip-10-0-1-10 ~]$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=110 time=1.29 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=110 time=1.34 ms

And I can SSH into the Acceptance instance by using Bastion as the proxy jump:

$ ssh acceptance
[ec2-user@ip-10-0-0-10 ~]$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=109 time=1.64 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=109 time=1.39 ms

We can even destroy everything we’ve just built using terraform destroy.

Conclusion

Even though the initial setup of the project was quite lengthy, adding new resources and making any changes to the infrastructure from this point on is a breeze. As this code is repeatable, this can even be executed in multiple environments and migrated (through branches on Git for example) from one environment to another.

Terraform is a very useful utility to manage your infrastructure. Using it will allow you to more easily scale and evolve your new or existing infrastructure, no matter where it runs.

Lucidus cloud