Generating dynamic subnets & AZs with Terraform's VPC module
Tags: terraform, aws, vpc
Published: 2024-01-22 13:14:35

**The Challenge:**

I often use the [Terraform VPC module](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest) when creating environments in AWS. And I'm an advocate for writing reusable code where possible... 

So it bugged me that my use of the VPC module relied on a hacky way to cater for setting up subnets in regions with differing numbers of Availability Zones. 

So typically I'd done something like this: 

```
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.1"

  name            = var.vpc-name
  cidr            = var.vpc-cidr
  azs             = ["${var.region}a", "${var.region}b", "${var.region}c"]
  intra_subnets   = [cidrsubnet(var.vpc-cidr, 8, 0), cidrsubnet(var.vpc-cidr, 8, 1), cidrsubnet(var.vpc-cidr, 8, 2)]
  private_subnets = [cidrsubnet(var.vpc-cidr, 8, 10), cidrsubnet(var.vpc-cidr, 8, 11), cidrsubnet(var.vpc-cidr, 8, 12)]

}
```

As you can see, I'd define the `region` and `vpc-cidr` variables and then when it comes to defining things like `intra_subnets` and `private_subnets` I'd use the [cidrsubnet](https://developer.hashicorp.com/terraform/language/functions/cidrsubnet) function to carve up the CIDR into three subnets. 

This approach is fine _**if**_ you're only deploying to a region with three AZs. 

But what if you want to deploy to ap-northeast-2 where there are 4 AZs, or us-east-1 where there are 6? 

In those cases you'd need to update the code to add additional entries for each AZ - then consider what shifting up or down you had to do with the `netnum` argument in the cidrsubnet function.

So overall I had functioning code that could be reasonably quickly amended to cater for other regions. But it still wasn't very dynamic and I suspected it could be improved to be so. 

**Making it better:**

Introducing the [aws_availability_zones](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/availability_zone) data source, along with the [index](https://developer.hashicorp.com/terraform/language/functions/index_function) function, and terraform [locals](https://developer.hashicorp.com/terraform/language/values/locals), allowed me to modify the code so it caters for differing numbers of availability zones dynamically - meaning no changes to the code are necessary when deploying into different regions. 

The code now looks like this: 

```
data "aws_availability_zones" "my_azs" {
  state = "available"
}

locals {
  azs = data.aws_availability_zones.my_azs.names
  intra_subnets = [
    for az in local.azs : cidrsubnet(var.vpc_cidr, var.intra_subnet_size, index(local.azs, az))
  ]
  private_subnets = [
    for az in local.azs : cidrsubnet(var.vpc_cidr, var.private_subnet_size, (index(local.azs, az) + length(local.azs)))
  ]
}

module "vpc" {
  source               = "terraform-aws-modules/vpc/aws"
  version              = "5.5.1"
  name                 = var.vpc_name
  cidr                 = var.vpc_cidr
  azs                  = local.azs
  intra_subnets        = local.intra_subnets
  private_subnets      = local.private_subnets
}


```
This is better, and if you just needed to see that example and you understand what's happening, the rest of this blog probably isn't useful to you. 

If you want to understand more about how the code above works, read on. 


Lets look at each section, in turn:
```
data "aws_availability_zones" "my_azs" {
  state = "available"
}
```
This retrieves a list of all 'available' availability zones in the region you're deploying into. 


This next section defining the locals is a little more complex, but it's not too bad when we break it down: 
```
  azs = data.aws_availability_zones.my_azs.names
```

`azs` is straight forward enough, it simply the list of AZ's that our data resource discovered for us. 


```
  intra_subnets = [
    for az in local.azs : cidrsubnet(var.vpc_cidr, var.intra_subnet_size, index(local.azs, az))
  ]
```
Defining `intra_subnets` relies on a 'for' loop.
This loop will iterate over each of the AZ's in our list and use that list to produce a subnet in each AZ regardless of the number of available AZs in the current region. 

As an example, given the following variable definitions: 
`vpc_cidr` = 10.0.0.0/16 
`intra_subnet_size` = 8  (This was introduced as a variable so you can specify different subnet sizes according to your need.)
`azs` = [ eu-west-1a, eu-west-1b, eu-west-1c] 

... the first pass of the 'for' loop would expand the cidrsubnet function like this: 
```
cidrsubnet(10.0.0.0/16, 8, (index(local.azs, eu-west-1a)))
```

Hopefully the first half of the cidrsubnet function should be clear now, you can see that the first subnet will have a /24 netmask (16+8), and that the subnet will be somewhere in the wider 10.0.0.0/16 range. But what position in that range? 

That's what `index` is being used for, to determine the value of `netnum` for the cidrsubnet function.

The `index` function, given a list, and an element in that list, will return the numerical position of that element in that list. e.g. eu-west-1a in the example above is position '0', eu-west-1b is position '1', & eu-west-1c is position '2'. 

Therefore the index portion in the example above evaluates to "0" giving us: 
```
cidrsubnet(10.0.0.0/16, 8, 0)
```

... meaning the first intra_subnet, for eu-west-1a, will be defined as 10.0.0.0/24. 

When the loop iterates over eu-west-1b it expands as before, but this time because eu-west-1b is position 1 in the list the 'for' loop will evaulate to 
```
cidrsubnet(10.0.0.0/16, 8, 1)
```
... meaning the second intra_subnet, for eu-west-1b, will be defined as 10.0.1.0/24... and so on. 


Moving onto the `private_subnets` definition: 
```
  private_subnets = [
    for az in local.azs : cidrsubnet(var.vpc_cidr, var.private_subnet_size, (index(local.azs, az) + length(local.azs)))
```
This works on exactly the same premise as before, but we need to prevent them overlapping with the `intra_subnets`. This is why the `length` function has been added.

This behaves as before, but the important thing to note here is that `length(local.azs)` will return a numerical value, equal to the length of the 'azs' list. 
It's used here to add to the index numerical vale to provide an offset equal to the number of AZs. 

Therefore with the same assumptions as before, for eu-west-1, this section will evaluate to: 
```
cidrsubnet(10.0.0.0/16, 8, 3)
cidrsubnet(10.0.0.0/16, 8, 4)
cidrsubnet(10.0.0.0/16, 8, 5)
```

Giving private subnet ranges of:
10.0.3.0/24
10.0.4.0/24
10.0.5.0/24


Because we use `length` to count the number of AZ's in the list it will always offset by the right amount. 

If you wanted to add a third set of subnets, for example `public_subnets`, you could use something like:


```
  public_subnets = [
    for az in local.azs : cidrsubnet(var.vpc_cidr, var.public_subnet_size, (index(local.azs, az) + length(local.azs)*2))
```

i.e. multiply the value of 'length' by 2 to give a third offset value.


**Pulling it together:**
So having defined the locals you now need to feed those into the VPC module. You can do that with something like: 

```
module "vpc" {
  source               = "terraform-aws-modules/vpc/aws"
  version              = "5.5.1"
  name                 = var.vpc_name
  cidr                 = var.vpc_cidr
  azs                  = local.azs
  intra_subnets        = local.intra_subnets
  private_subnets      = local.private_subnets
}
```

The beauty of this is that regardless of whether we deploy to eu-west-1 or us-east-1, we've produced code that will deploy the right number of subnets. It will also offset by the correct amount in each region to prevent overlapping ranges. 


**Further reading:**
[Terraform Data Sources](https://developer.hashicorp.com/terraform/language/data-sources)
[Terraform locals](https://developer.hashicorp.com/terraform/language/values/locals)