Understandable Terraform projects

Didrik Finnoy
ITNEXT
Published in
6 min readApr 13, 2022

--

https://abstrusegoose.com/432
https://abstrusegoose.com/strips/you_down_wit_OPC-yeah_you_know_me.png

Terraform code is declarative. We use it to declare what we want from our cloud providers. If one could translate this code into plain English, it would read like an elaborate shopping list:

Give me a private virtual network with a database and a kubernetes cluster. The cluster should have some number of nodes, and they should all use this particular type of CPU. The database should be located in this part of the world, and it should have the capacity to store some number of gigabytes…

The desired state that we are describing tends to be quite complicated; this is why Terraform projects often become difficult to understand.

The purpose of this article is to share how we approach the problem of writing understandable Terraform code at Bulder Bank.

YAML flavoured input values

Most people write their input values as HCL in .tfvars files. I prefer YAML because it’s easier to read. When you define the input values for your Terraform root modules with YAML, the desired state becomes easier to interpret:

# ./my-project/live/prod/config.yaml

networks:
- name: network-a
region: europe-west1
- name: network-b
region: europe-north1

databases:
- name: database-number1
type: cloudsql
network: network-a
region: europe-west1-a
disk_size: 20gb
- name: database-number2
type: postgresql
network: network-b
region: europe-north1-b
disk_size: 40gb

clusters:
- name: prod-blue
region: europe-west1-a
network: network-a
min_nodes: 3
max_nodes: 6
- name: prod-green
region: europe-west1-b
network: network-a
min_nodes: 3
max_nodes: 6

The equivalent in HCL is pretty noisy:

networks = [
{
name = "network-a"
location = "europe-west1"
},
{
name = "network-b"
location = "europe-west1"
}
]

databases = [
{
name = "database-number1"
type = "cloudsql"
network = "network-a"
location = "europe-west1-a"
disk_size = "20gb"
},
{
name = "database-number2"
type = "postgresql"
network = "network-b"
region = "europe-west1-b"
disk_size = "40gb"
}
]

# etc..

YAML and HCL support the same basic collections. This makes it easy to convert YAML to HCL; there is even a built in function in Terraform for doing this (yamldecode()).

YAML configuration can be made available to Terraform with the following trick:

# ./live/*/locals.tflocals {
config = yamldecode(file("./config.yaml"))
}

This makes all the content from config.yaml accessible within the .tf files via the local.config object.

Being configuration oriented

A key strategy for writing understandable Terraform code is to be configuration oriented. By configuration, I am referring to input values that will be passed to Terraform modules. As demonstrated above, these values can be organized in a way that almost feels like documentation. Functional documentation. Documentation that dictates what happens when the Terraform code is executed.

Being configuration oriented means that you are minimizing the number of files that engineers are likely to interact with in the long-term. An engineer only revisits old Terraform code because they want to understand (or modify) the desired state. If you structure your input values as illustrated above, most code visits can be limited to the config.yaml file. Anyone who understands YAML will intuitively understand how to add/remove databases, clusters and networks. Simple tasks like increasing the size of a database also become very intuitive.

Directory structure

A common pitfall of many Terraform projects is that they are poorly structured. I’ve seen repositories with dozens of .tf files, where the reader doesn't even know where to begin.

At Bulder Bank we have a fixed directory layout for all our Terraform projects. Assume we want to deploy resources across two environments (dev and prod):

└── my-project
├── live
│ ├── dev
│ │ ├── config.yaml
│ │ ├── modules.tf
│ │ ├── providers.tf
│ │ ├── locals.tf
│ │ └── terraform.tf
│ └── prod
│ ├── config.yaml
│ ├── modules.tf
│ ├── providers.tf
│ ├── locals.tf
│ └── terraform.tf
└── modules
├── kubernetes
│ └── main.tf
├── network
│ └── main.tf
└── database
└── main.tf

Every Terraform project in our GitHub organization is structured like this, making them easy for our engineers to navigate.

Using modules

There is nothing special about the Terraform modules we put in the modules/ directory. They are written in HCL, and typically expect simple input values like strings, numbers or lists. If they are used across multiple root modules, we store them in their own GitHub repositories for reusability.

One opinionated style decision we follow, that we do not use resource blocks in the root module for projects where there are multiple environments (eg. prod / dev / staging). In other words, we only use module and data blocks in modules.tf. There are some nice benefits to this approach:

  • The modules.tf files are always identical between different environments under live/ (easy to copy-paste).
  • The Terraform code becomes easier to modify across multiple environments.
  • terraform statecommands become easier to handle.

For single environment projects there is no need for this style limitation.

The modules.tf files for our YAML configuration example would look something like this:

# ./my-project/live/*/modules.tf

module "network" {
for_each = { for x in local.config.networks : x.name => x }
source = "../../modules/network"

name = each.value.name
region = each.value.region
}

module "database" {
for_each = { for x in local.config.databases : x.name => x }
source = "../../modules/database"

name = each.value.name
type = each.value.type
region = each.value.region
disk_size = each.value.disk_size
network = module.network[each.value.network].name
}

module "kubernetes" {
for_each = { for x in local.config.clusters : x.name => x }
source = "../../modules/kubernetes"

name = each.value.name
region = each.value.region
min_nodes = each.value.min_nodes
max_nodes = each.value.max_nodes
network = module.network[each.value.network].name
}

Default values

Terraform modules often contain a lot of input values. Including all of these in the YAML configuration files could have a negative impact on their readability. When we write our own modules, we can use default values within the modules/ directory to keep unwanted complexity out of our YAML files. A problem one encounters with the YAML flavoured approach, is how to support optional values in config.yaml. As of Terraform v1.1.0, there is a neat trick for deferring to the module's default values.

Lets assume that the max_nodes variable in the kubernetes module defaults to 6. We want the ability to overwrite this default in config.yaml , but we also want Terraform to use the default value of 6 if the variable is not specified in config.yaml:

# ./my-project/live/prod/config.yaml

clusters:
- name: prod-blue
region: europe-west1-a
network: network-a
min_nodes: 3
- name: prod-green
region: europe-west1-b
network: network-a
min_nodes: 3
max_nodes: 12
# ./my-project/modules/kubernetes/main.tf

variable "max_nodes" {
default = 6
nullable = false
}
# ./my-project/live/*/modules.tf

module "kubernetes" {
for_each = { for x in local.config.clusters : x.name => x }
source = "../../modules/kubernetes"

name = each.value.name
region = each.value.region
min_nodes = each.value.min_nodes
max_nodes = lookup(each.value, "max_nodes", null)
network = module.network[each.value.network].name
}

The nullable = false option in main.tf means that if the module receives a null value from modules.tf, it will fall back to the default value of 6. Above, the max_nodes for prod-blue will be 6, while prod-green will be 12.

For cases where you are not in control of a Terraform module being sourced in modules.tf, you may not have the option of defining the nullable field (it defaults to true). If you want a value to be both configurable and optional in config.yaml, you could simply provide a default value within the lookup() command in modules.tf Be careful though, the more you do this, the more difficult it becomes for an engineer to puzzle out how some module is configured. I only tend to put default values in the lookup() function when the module does not have an appropriate default value, and I'm unlikely to care about the input value in the long-term.

Conclusion

I’ve been working with Terraform for several years, and been exposed to Terraform projects across multiple organizations. One of the things I’ve noticed is that practices differ a lot; there doesn’t seem to be a standardized approach to organizing Terraform projects. Terraform code should be written for the long-term; writing understandable code is the equivalent of doing your colleagues (and future self) a big favor.

I hope you find our approach to Terraform helpful when creating / refactoring your own projects.

--

--