Terraform tips, tricks and best-practise

D. Heinrich
5 min readDec 23, 2019

UPDATES:

  • 2023–02–22 Removed VMware content as it was obsolet
  • 2023–02–22 Added standard best-practise
  • 2023–01–31 Added example for setproduct

Best-Practise

Use version control

  • Store your Terraform configurations in a version control system (VCS) like Git. This will allow you to track changes over time, collaborate with others, and roll back changes if needed.
$ git init
$ git add .
$ git commit -m "Initial commit"

Use modules

  • Modules help you organize and reuse your Terraform code. Use them to encapsulate common functionality and make your code more modular and maintainable.
// main.tf
module "vpc" {
source = "./modules/vpc"

name = "my-vpc"
cidr_block = "10.0.0.0/16"

// other configuration options...
}

// modules/vpc/main.tf
resource "aws_vpc" "vpc" {
cidr_block = var.cidr_block

tags = {
Name = var.name
}

// other configuration options...
}

// modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.vpc.id
}

In this example, we have a main Terraform configuration file that calls a Terraform module to create an AWS VPC. The module is located in the ./modules/vpc directory, and contains a main.tf file and an outputs.tf file.

In the main.tf file of the module, we define an aws_vpc resource that creates an AWS VPC with the cidr_block and tags specified in the module variables. We can also add other configuration options for the VPC in this file.

In the outputs.tf file of the module, we define an output variable called vpc_id, which outputs the ID of the VPC created by the aws_vpc resource.

Back in the main Terraform configuration file, we call the vpc module using the module block. We specify the source of the module as ./modules/vpc, and pass in the name and cidr_block variables as module inputs.

This modular approach helps make our Terraform code more modular and reusable, as we can easily create multiple VPCs with different configurations simply by calling the vpc module with different input variables. It also helps keep our code organized and easier to maintain, as each module can be developed and tested separately.

Use variables and outputs

  • Use variables to parameterize your code and make it more reusable. Use outputs to expose important information, such as resource IDs, to other Terraform code or external tools.
variable "aws_region" {
default = "us-west-2"
}

output "vpc_id" {
value = module.vpc.vpc_id
}

Use environments

  • Use separate environments, such as development, staging, and production, to test and deploy your infrastructure. Use Terraform workspaces to manage different environments in the same codebase.
terraform workspace new dev
terraform workspace select dev

terraform workspace new prod
terraform workspace select prod

Use remote state

  • Use a remote backend, such as Amazon S3 or HashiCorp Consul, to store your Terraform state. This makes it easier to collaborate with others and avoid conflicts.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "terraform.tfstate"
region = "us-west-2"
}
}

Use automated testing

  • Use automated tests, such as integration tests and acceptance tests, to verify that your infrastructure is working as expected. Use tools like Terratest to automate testing.
package test

import (
"testing"

"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)

func TestVpc(t *testing.T) {
// Set up Terraform options with path to Terraform code and variables
terraformOptions := &terraform.Options{
TerraformDir: "../terraform/modules/vpc",

Vars: map[string]interface{}{
"name": "my-vpc",
"cidr": "10.0.0.0/16",
},
}

// Clean up resources when the test is done
defer terraform.Destroy(t, terraformOptions)

// Deploy the VPC using Terraform
terraform.InitAndApply(t, terraformOptions)

// Get the VPC ID from the Terraform output
vpcId := terraform.Output(t, terraformOptions, "vpc_id")

// Get the VPC details using the AWS SDK
vpc, err := aws.GetVpcByIdE(t, vpcId, "us-west-2")
if err != nil {
t.Fatal(err)
}

// Verify that the VPC has the correct tags and CIDR block
assert.Equal(t, "my-vpc", aws.GetTagValue(t, "Name", vpc.Tags))
assert.Equal(t, "10.0.0.0/16", *vpc.CidrBlock)
}

In this example, we’re using Terratest to deploy a Terraform module that creates an AWS VPC, and then test that the VPC has the correct tags and CIDR block.

We define Terraform options that point to the directory containing our Terraform code, as well as variables that we want to pass into our Terraform code. We then use terraform.InitAndApply to deploy the Terraform code, and terraform.Output to get the VPC ID from the Terraform output.

Next, we use the AWS SDK through the aws module in Terratest to retrieve the VPC details from AWS, using the VPC ID we just retrieved. We then use assert.Equal to check that the VPC has the correct tags and CIDR block.

This automated test helps ensure that our infrastructure is working as expected, and can be run as part of a CI/CD pipeline to catch any issues early on.

Use conditional logic

  • Use conditional logic, such as if statements and for loops, to make your code more flexible and reusable. Use Terraform’s built-in functions and operators to implement conditional logic.
locals {
create_rds_instance = var.environment == "prod"
}

resource "aws_db_instance" "example" {
count = local.create_rds_instance ? 1 : 0

identifier = "example-db"
engine = "postgres"
instance_class = "db.t3.micro"
username = "admin"
password = "P@ssw0rd"

// other configuration options...

tags = {
Environment = var.environment
}
}

In this example, we’re using a local variable called create_rds_instance to determine whether or not to create an AWS RDS instance. The value of this variable is determined based on the value of the environment variable passed into Terraform.

If environment is set to "prod", then create_rds_instance will be true, and Terraform will create an instance of the aws_db_instance resource with the identifier "example-db". Otherwise, create_rds_instance will be false, and Terraform will not create this resource at all.

Using conditional logic like this can help make your Terraform code more flexible and reusable, as you can easily enable or disable certain resources based on different conditions, without having to duplicate code or create separate Terraform configurations.

Tipps and Tricks

Setproduct

The setproduct function finds all of the possible combinations of elements from all of the given sets by computing the Cartesian product.

I used this to set the product from two lists with different length’s.

In this particular case I tried to aggregate my AWS-Subnet Route-Table-IDs with a list of destination CIDRs I want to add as a Route.

The Example below is just on how to use the setproduct function.


locals {
regions = ["us-west-1", "us-west-2"]
instance_types = ["t2.micro", "t2.small", "t2.medium"]
}

resource "aws_instance" "ec2" {
count = length(setproduct(local.regions, local.instance_types))

ami = "ami-0c55b159cbfafe1f0"
instance_type = element(element(setproduct(local.instance_types, local.regions), count.index), 0)
subnet_id = aws_subnet.private[count.index % length(aws_subnet.private)].id

// other configuration options...
}

In this example, we have a set of two regions (us-west-1 and us-west-2) and three instance types (t2.micro, t2.small, and t2.medium). We use the setproduct function to create a set of all possible combinations of these two sets.

We then use the count argument on the aws_instance resource to create an EC2 instance for each combination of region and instance type. The count value is set to the length of the set product, which gives us a total of six instances (two regions times three instance types).

To set the instance_type and subnet_id for each instance, we use the element function with nested setproduct and count index values. We first create a set product of the instance types and regions, and then use the count.index value to select the appropriate combination for each instance. We use the subnet_id of one of the private subnets in the VPC, which we assume has already been created.

This approach helps automate the creation of multiple instances with different configurations, and ensures that each instance is created in a separate subnet in the VPC.

--

--

D. Heinrich

Working as a Head of Infrastructure at Flower Labs.