Amazon Virtual Private Clouds with Terraform: An Introduction & Tutorial

Run your AWS VPCs with Terraform. Live to tell about it.

  • Public subnet
  • Private subnet
  • Internet gateway (IG)
  • Network address translation (NAT) gateway
  • Network access control list (ACL)

Why Terraform?

At Stedi, we chose Terraform because we need several separate environments that mimic our production system. Often, a production environment suffers from configuration drift — a loathsome phenomenon in which undocumented or ad hoc changes lead, over time, to a unique configuration that’s tough to replicate. Terraform prevents configuration drift by forcing your team to define and provision your infrastructure in code, rather than clicking around in the AWS console. By codifying your infrastructure in this way, you’re able to track changes, document the current state of your environmental variables and configuration settings, and keep your environments in sync.

Prerequisites

Before jumping into our project, we need to install Terraform. We will also create an AWS user specifically for Terraform.

1. Install Terraform

Terraform has some pretty straightforward installation instructions, but here’s the TL;DR:

  • Mac + Homebrew: Run brew install terraform.
  • Mac/Linux: Download and unzip the correct package for your operating system and move the binary somewhere that’s on your PATH, e.g. /usr/local/bin.
  • Windows: Download the correct package and set the binary on your PATH.
$ terraform -v
Terraform v0.11.1

2. Create an AWS user

Login to AWS, navigate to the ‘Users’ section of Identity Access and Management (IAM), and click the blue ‘Add user’ button. Enter terraform_user as the ‘User name’, select ‘Programmatic access’ as the ‘Access Type’, and click the blue ‘Next’ button. On the next screen, click ‘Create group’, select ‘AdministratorAccess’, and click the blue ‘Create group’ button. You will then be shown your new user’s ‘Access key ID’ and ‘Secret access key’. This is the only time you’ll be able to view your secret access key, so copy these two values temporarily, as we’ll need them in the next step.

[terraform_user]
aws_access_key_id = XXXXXXXXXXXXXXXXXXXX
aws_secret_access_key = XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Getting started

Let’s begin by launching a new AWS VPC. Create an empty directory that will contain your Terraform project, cd into it, and create the following file, named main.tf.

// main.tf// Specify AWS as the provider and reference your credentials
provider "aws" {
profile = "terraform_user"
region = "us-east-1"
}
// Create a VPC
resource "aws_vpc" "vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags {
Name = "my-first-vpc"
}
}

Reusing resources with modules

So far so good, but it’d be nice if we could use the same block of code to create our development and production VPCs, since only one line of code (the tag) changes between them. Fortunately, Terraform provides us with a way to do this: modules.

//  modules/vpc/vpc.tfvariable "name" { }
variable "cidr" { default = "10.0.0.0/16" }
// Create a VPC with a configurable name (and cidr)
resource "aws_vpc" "vpc" {
cidr_block = "${var.cidr}"
enable_dns_support = true
enable_dns_hostnames = true
tags {
Name = "${var.name}"
}
}
// main.tf// Specify AWS as the provider and reference credentials
provider "aws" {
profile = "terraform_user"
region = "us-east-1"
}
// Create a VPC tagged in AWS with "my-second-vpc"
module "vpc" {
source = "./modules/vpc"
name = "my-second-vpc"
}

Creating the development VPC

We’re going to build our development environment by following the same module pattern we used above to create our first VPC. We’ll be adding modules for a public subnet, private subnet, and a NAT gateway. Here’s what our directory structure will look like by the end of this tutorial — go ahead and create these directories now:

- modules/                     // define shared components
-------- nat/
-------- private-subnet/
-------- public-subnet/
-------- vpc/
-------- environment/
- us-east-1-dev/ // our development environment
- us-east-1-prod/ // our production environment
//  us-east-1-dev/main.tf// Specify AWS as the provider and reference credentials
provider "aws" {
region = "us-east-1"
profile = "terraform_user"
}
// Create a VPC
module "vpc" {
source = "../modules/vpc"
name = "dev-vpc"
}
// Create a public subnet referencing the vpc
module "public_subnet" {
source = "../modules/public-subnet"
name = "public-subnet"
vpc_id = "${module.vpc.vpc_id}"
cidrs = "10.0.1.0/24,10.0.2.0/24"
azs = "us-east-1a,us-east-1b" // availability zones
}
//   modules/public-subnet/public-subnet.tfvariable "name"   { default = "public" }
variable "vpc_id" { }
variable "cidrs" { }
variable "azs" { }
// Create the public subnet
resource "aws_subnet" "public" {
vpc_id = "${var.vpc_id}"
cidr_block = "${element(split(",", var.cidrs), count.index)}"
availability_zone = "${element(split(",", var.azs), count.index)}"
count = "${length(split(",", var.cidrs))}"
tags {
Name = "${var.name}.${element(split(",", var.azs),count.index)}"
}
}
// Create an internet gateway
resource "aws_internet_gateway" "public" {
vpc_id = "${var.vpc_id}"
tags {
Name = "${var.name}-subnet"
}
}
// Create a public route table
// Allow outbound traffic to the internet gateway
resource "aws_route_table" "public" {
vpc_id = "${var.vpc_id}"
route {
cidr_block = "0.0.0.0/0"
gateway_id = "${aws_internet_gateway.public.id}"
}
tags {
Name = "${var.name}.${element(split(",", var.azs),count.index)}"
}
}
// Associate the subnet with the public route table
resource "aws_route_table_association" "public" {
count = "${length(split(",", var.cidrs))}"
subnet_id = "${element(aws_subnet.public.*.id, count.index)}"
route_table_id = "${aws_route_table.public.id}"
}
// Export the subnet IDs so we can reference them from elsewhere
output "subnet_ids" {
value = "${join(",", aws_subnet.public.*.id)}"
}
...output "vpc_id" {
value = "${aws_vpc.vpc.id}"
}
...module "private_subnet" {
source = "../modules/private-subnet"
name = "private-subnet"
vpc_id = "${module.vpc.vpc_id}"
cidrs = "10.0.11.0/24,10.0.12.0/24"
azs = "us-east-1a,us-east-1b"
nat_gateway_ids = "${module.nat.nat_gateway_ids}"
}
module "nat" {
source = "../modules/nat"
name = "nat"
public_subnet_ids = "${module.public_subnet.subnet_ids}"
}
// us-east-1-dev/main.tfvariable "azs" { default = “us-east-1a,us-east-1b” }...
azs = "${var.azs}"
//   modules/private-subnet/private-subnet.tfvariable "name" { default = "private"}
variable "vpc_id" { }
variable "cidrs" { }
variable "azs" { }
variable "nat_gateway_ids" { }
resource "aws_subnet" "private" {
vpc_id = "${var.vpc_id}"
cidr_block = "${element(split(",", var.cidrs), count.index)}"
availability_zone = "${element(split(",", var.azs), count.index)}"
count = "${length(split(",", var.cidrs))}"
lifecycle { create_before_destroy = true }
tags {
Name = "${var.name}.${element(split(",", var.azs),count.index)}"
}
}
resource "aws_route_table" "private" {
vpc_id = "${var.vpc_id}"
count = "${length(split(",", var.cidrs))}"
lifecycle { create_before_destroy = true }
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = "${element(split(",", var.nat_gateway_ids), count.index)}"
}
tags {
Name = "${var.name}.${element(split(",", var.azs),count.index)}"
}
}
resource "aws_route_table_association" "private" {
count = "${length(split(",", var.cidrs))}"
subnet_id = "${element(aws_subnet.private.*.id, count.index)}"
lifecycle { create_before_destroy = true }
route_table_id = "${element(aws_route_table.private.*.id, count.index)}"
}
output "subnet_ids" {
value = "${join(",", aws_subnet.private.*.id)}"
}
//   modules/nat/nat.tfvariable "name" { default = "nat" }
variable "public_subnet_ids" { }

resource "aws_eip" "nat" {
vpc = true
lifecycle { create_before_destroy = true }
}

resource "aws_nat_gateway" "nat" {
allocation_id = "${element(aws_eip.nat.*.id, count.index)}"
subnet_id = "${element(split(",", var.public_subnet_ids), count.index)}"
tags {
Name = "${var.name}"
}

lifecycle { create_before_destroy = true }
}

output "nat_gateway_ids" {
value = "${join(",", aws_nat_gateway.nat.*.id)}"
}
...resource "aws_network_acl" "nacl" {
vpc_id = "${module.vpc.vpc_id}"
subnet_ids = [
"${split(",", module.private_subnet.subnet_ids)}",
"${split(",", module.public_subnet.subnet_ids)}"
]
egress {
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
ingress {
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags {
Name = "network-acl"
}
}
// Create a variable to store an environment's name
variable "environment_name" {}
...module "vpc" {
source = "../modules/vpc"
name = "${var.environment_name}-vpc"
}
module "vpc" {
source = "../vpc"
name = "${var.environment_name}-vpc"
}
// us-east-1-dev/main.tfmodule "environment" {
source = "../modules/environment"
environment_name = "dev"
}

What’s Next?

At this point, you should be feeling pretty good about your devops chops — you now know how to spin up and tear down matching production and development environments from the command line. In Part 2 of this tutorial, we’ll learn how to access your VPC using OpenVPN Access Server.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Adam Gibbons

Outdoorsman, roadie, Colorado Buffalo, DC native, freelance web developer.