When building Terraform modules, it is a common requirement to want to allow the client to be able to choose which region resources are created in, and which availability zones are used.

I've seen a few ways of doing this, none of which felt entirely satisfactory. After a bit of experimentation I've come up with a solution which I think really works nicely. This solution avoids having to know in advance how many availability zones we'll support.

screenshot

To demonstrate, I've set up a module which deploys a cluster of web servers. My goal is to be able to configure the region, VPC CIDR block, subnets and subnet CIDR blocks as below:

module "cluster" {
  source            = "github.com/dwmkerr/terraform-aws-vpc"

  # Note how we can specify any number of availability zones here...
  region            = "ap-northeast-2"
  vpc_cidr          = "10.0.0.0/16"
  subnets           = {
    ap-northeast-2a = "10.0.1.0/24"
    ap-northeast-2b = "10.0.2.0/24"
    ap-northeast-2c = "10.0.3.0/24"
  }

  # This just defines the number of web servers to deploy, and uses
  # adds my public key so I can SSH into the servers...
  web_server_count  = "3"
  public_key_path   = "~/.ssh/id_rsa.pub"

}

The example module is at github.com/dwmkerr/terraform-aws-vpc. Let's take a look at some of the key elements.

The Variables

We define the required variables very explicitly, with descriptions and a variable type to avoid confusion:

variable "region" {
  description = "The region to deploy the VPC in, e.g: us-east-1."
  type = "string"
}

variable "vpc_cidr" {
  description = "The CIDR block for the VPC, e.g: 10.0.0.0/16"
  type = "string"
}

variable "subnets" {
  description = "A map of availability zones to CIDR blocks, which will be set up as subnets."
  type = "map"
}

The VPC

Now that we have defined the variables, we can set up the VPC:

//  Define the VPC.
resource "aws_vpc" "cluster" {
  cidr_block           = "${var.vpc_cidr}"
  enable_dns_hostnames = true
}

//  An Internet Gateway for the VPC.
resource "aws_internet_gateway" "cluster_gateway" {
  vpc_id = "${aws_vpc.cluster.id}"
}

//  Create one public subnet per key in the subnet map.
resource "aws_subnet" "public-subnet" {
  count                   = "${length(var.subnets)}"
  
  vpc_id                  = "${aws_vpc.cluster.id}"
  cidr_block              = "${element(values(var.subnets), count.index)}"
  map_public_ip_on_launch = true
  depends_on              = ["aws_internet_gateway.cluster_gateway"]
  availability_zone       = "${element(keys(var.subnets), count.index)}"
}

//  Create a route table allowing all addresses access to the IGW.
resource "aws_route_table" "public" {
  vpc_id       = "${aws_vpc.cluster.id}"

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

//  Now associate the route table with the public subnet - giving
//  all public subnet instances access to the internet.
resource "aws_route_table_association" "public-subnet" {
  count          = "${length(var.subnets)}"
  
  subnet_id      = "${element(aws_subnet.public-subnet.*.id, count.index)}"
  route_table_id = "${aws_route_table.public.id}"
}

There are a few things of interest here. First, we can easily build a variable number of subnets by using the count field on the aws_subnet resource:

resource "aws_subnet" "public-subnet" {
  count                   = "${length(var.subnets)}"
  
  availability_zone       = "${element(keys(var.subnets), count.index)}"
  cidr_block              = "${element(values(var.subnets), count.index)}"
}

By using the Terraform Interpolation Syntax, and in particular the count, keys, values and element functions, we can grab the subnet name and CIDR block from the variables.

The Web Server Cluster

A cluster of web servers behind a load balancer are created by the module, to demonstrate that it works. There is little of interest in the script except for how the subnets are referenced:

resource "aws_autoscaling_group" "cluster_node" {
  name                        = "cluster_node"
  vpc_zone_identifier         = ["${aws_subnet.public-subnet.*.id}"]
  launch_configuration        = "${aws_launch_configuration.cluster_node.name}"
}

Note that we can specify the entire list of subnet ids by using the * symbol in the resource path - ["${aws_subnet.public-subnet.*.id}"].

That's It!

That's really all there is to it. I quite like this approach. I think it makes it very clear what is going on with the infrastructure, and is fairly manageable.

One question which may be raised is why I am not using the cidrsubnet function to automatically calculate the CIDR blocks for the subnets. The reason is purely one of preference - I prefer to explicitly specify the CIDR blocks and use various patterns to set conventions. For example, if I see an IP address such as 10.0.3.121 then it is in the third AZ of my public subnet, or 10.2.2.11 is in the second AZ of my locked down data zone.

You can see a sample Terraform module which uses this pattern at: github.com/dwmkerr/terraform-aws-vpc-example. This module also has a basic build pipeline and is published on the Terraform Registry. I'll also be updating my AWS Openshift module to use this pattern.