DEV Community

Cover image for Some Techniques to Enhance Your Terraform Proficiency
Serhii Vasylenko for AWS Community Builders

Posted on • Originally published at devdosvid.blog on

Some Techniques to Enhance Your Terraform Proficiency

Terraform built-in functionality is very feature-rich: functions, expressions, and meta-arguments provide many ways to shape the code and fit it to a particular use case.

I want to share a few valuable practices to boost your Terraform expertise in this blog.

Two notes:

1️⃣ Some code examples in this article will work with Terraform version 0.15 and onwards. But if you’re still using 0.14 or lower, here’s another motivation for you to upgrade

2️⃣ I'll be using AWS Terraform provider code examples throughout the article but mentioned Terraform functions and expressions are provider-agnostic and will work the same with other providers

Conditional resources creation

Conditional resources creation

Let’s start from the most popular one (although, still may be new for somebody): whether to create a resource depending on some fact, e.g., the value of a variable. Terraform meta-argument count helps to describe that kind of logic.

Here is how it may look like:

data "aws_ssm_parameter" "ami_id" {
  count = var.ami_channel == "" ? 0 : 1

  name = local.ami_channels[var.ami_channel]
}
Enter fullscreen mode Exit fullscreen mode

The notation var.ami_channel == "" ? 0 : 1 is called conditional expression and means the following: if my variable is empty (var.ami_channel == "" — hence, true) then set the count to 0, otherwise set to 1.

condition ? true_val : false_val
Enter fullscreen mode Exit fullscreen mode

In this illustration, I want to get the AMI ID from the SSM Parameter only if the AMI channel (e.g., beta or alpha) is specified. Otherwise, providing that the ami_channel variable is an empty string by default (""), the data source should not be created.

When following this method, keep in mind that the resource address will contain the index identifier. So when I need to use the value of the SSM parameter from our example, I need to reference it the following way:

ami_id = data.aws_ssm_parameter.ami_id[0].value
Enter fullscreen mode Exit fullscreen mode

The count meta-argument can also be used to create a whole module conditionally:

module "bucket" {
  count = var.create_bucket == true ? 1 : 0
  source = "./modules/s3_bucket"

  name = "my-unique-bucket"
  ...
}
Enter fullscreen mode Exit fullscreen mode

The var.create_bucket == true ? 1 : 0 expression can be written even shorter: var.create_bucket ? 1 : 0 because the create_bucket variable has boolean type, apparently.

But what if you need to produce more than one instance of a resource or module? And still be able to avoid their creation.

Another meta-argument — for_each — will do the trick.

For example, this is how it looks for a module:

module "bucket" {
  for_each = var.bucket_names == [] ? [] : var.bucket_names
  source = "./modules/s3_bucket"

  name = "${each.key}"
  enable_encryption = true
  ...
}
Enter fullscreen mode Exit fullscreen mode

In this illustration, I also used a conditional expression that makes Terraform iterate through the set of values of var.bucket_names if it’s not empty and create several modules. Otherwise, do not iterate at all and do not create anything.

The same can be done for the resources. For example, when you need to create an arbitrary number of security group rules, e.g., to allowlist some IPs for your bastion host:

resource "aws_security_group_rule" "allowlist" {
  for_each = var.cidr_blocks == [] ? [] : var.cidr_blocks
  type = "ingress"
  from_port = 22
  to_port = 22
  protocol = "tcp"
  cidr_blocks = [each.value]
  security_group_id = aws_security_group.bastion.id
}
Enter fullscreen mode Exit fullscreen mode

And just like with the count meta-argument, with the for_each, resource addresses will have the identifier named by the values provided to for_each. For example, here is how I would reference a resource created in the module with for_each described earlier:

bucket_name = module.bucket["photos"].name
Enter fullscreen mode Exit fullscreen mode

Conditional resource arguments (attributes) setting

Conditional resource arguments

Now let’s go deeper and see how resource arguments can be conditionally set (or not).

First, let’s review the conditional argument value setting with the null data type:

resource "aws_launch_template" "this" {
  name = "my-launch-template"
  ...
  key_name = var.use_default_keypair ? var.keypair_name : null
  ...
Enter fullscreen mode Exit fullscreen mode

Here I want to skip the usage of the EC2 Key Pair for the Launch Template in some instances and Terraform allows me to write the conditional expression that will set the null value for the argument. It means the absence or omission and Terraform would behave the same as if you did not specify the argument at all.

Dynamic blocks are another case where this technique suits best. Take a look at the following piece of CloudFront resource code where I want to either describe the configuration for the custom error response or omit that completely:

resource "aws_cloudfront_distribution" "cdn" {
  enabled = true
  ...
  dynamic "custom_error_response" {
    for_each = var.custom_error_response == null ? [] : [var.custom_error_response]
    iterator = cer
    content {
      error_code = lookup(cer.value, "error_code", null)
      error_caching_min_ttl = lookup(cer.value, "error_caching_min_ttl", null)
      response_code = lookup(cer.value, "response_code", null)
      response_page_path = lookup(cer.value, "response_page_path", null)
    }
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

The custom_error_response variable is null by default, but it has the object type, and users can assign the variable with the required nested specifications if needed. And when they do it, Terraform will add the custom_error_response block to the resource configuration. Otherwise, it will be omitted entirely.

Convert types with ease

Convert types with ease

Ok, let’s move to the less conditional things now 😅

Terraform has several type conversion functions: tobool(), tolist(),tomap(), tonumber(), toset(), and tostring(). Their purpose is to convert the input values to the compatible types.

For example, suppose I need to pass the set to the for_each (it accepts only sets and maps types of value), but I got the list as an input; let’s say I got it as an output from another module. In such a case, I would do something like this:

for_each = toset(var.remote_access_ports)

Enter fullscreen mode Exit fullscreen mode

However, I can make my code cleaner and avoid the explicit conversion — I just need to define the value type in the configuration block of the my_list variable. Terraform will do the conversion automatically when the value is assigned.

variable "remote_access_ports" {
  description = "Ports for remote access"
  type = set(string)
}
Enter fullscreen mode Exit fullscreen mode

While Terraform can do a lot of implicit conversions for you, explicit type conversions are practical during values normalization or when you need to calculate some complex value for a variable. For example, the Local Values, known as locals, are the most suitable place for doing that.

By the way, although there is a tolist() function, there is no such thing as the tostring() function. But what if you need to convert the list to string in Terraform?

The one() function can help here: it takes a list, set, or tuple value with either zero or one element and returns either null or that one element in the form of string.

It’s useful in cases when a resource created using conditional expression is represented as either a zero- or one-element list, and you need to get a single value which may be either null or string, for example:

resource "aws_kms_key" "main" {
  count = var.ebs_encrypted ? 1 : 0

  enable_key_rotation = true
  tags = var.tags
}

resource "aws_kms_alias" "main" {
  count = var.ebs_encrypted ? 1 : 0

  name = "alias/encrypt-ebs"
  target_key_id = one(aws_kms_key.main[*]key_id)
}
Enter fullscreen mode Exit fullscreen mode

Write YAML or JSON as Terraform code (HCL)

Write YAML or JSON as Terraform code

Sometimes you need to supply JSON or YAML files to the services you manage with Terraform. For example, if you want to create something with CloudFormation using Terraform (and I am not kidding). Sometimes the AWS Terraform provider does not support the needed resource, and you want to maintain the whole infrastructure code using only one tool.

Instead of maintaining another file in JSON or YAML format, you can embed JSON or YAML code management into HCL by taking benefit of the jsonencode() or yamlencode() functions.

The attractiveness of this approach is that you can reference other Terraform resources or their attributes right in the code of your object, and you have more freedom in terms of the code syntax and its formatting comparable to native JSON or YAML.

Here is how it looks like:

locals {
    some_string = "ult"
  myjson_object = jsonencode({
    "Hashicorp Products": {
      Terra: "form"
      Con: "sul"
      Vag: "rant"
      Va: local.some_string
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

The value of the myjson_object local variable would look like this:

{
  "Hashicorp Products": {
    "Con": "sul",
    "Terra": "form",
    "Va": "ult",
    "Vag": "rant"
  }
}
Enter fullscreen mode Exit fullscreen mode

And here is a piece of real-world example:

locals {
  cf_template_body = jsonencode({
    Resources : {
      DedicatedHostGroup : {
        Type : "AWS::ResourceGroups::Group"
        Properties : {
          Name : var.service_name
          Configuration : [
            {
              Type : "AWS::EC2::HostManagement"
              Parameters : [
                {
                  Name : "auto-allocate-host"
                  Values : [var.auto_allocate_host]
                },
            ...
            ...
Enter fullscreen mode Exit fullscreen mode

Templatize stuff

Templatize stuff

The last case in this blog but not the least by its efficacy — render source file content as a template in Terraform.

Let’s review the following scenario: you launch an EC2 instance and want to supply it with a bash script (via the user-data parameter) for some additional configuration at launch.

Suppose we have the following bash script instance-init.sh that sets the hostname and registers our instance in a monitoring system:

#!/bin/bash

hostname example.com
bash /opt/system-init/register-monitoring.sh
Enter fullscreen mode Exit fullscreen mode

But what if you want to set a different hostname per instance, and some instances should not be registered in the monitoring system?

In such a case, here is how the script file content will look:

#!/bin/bash

hostname ${system_hostname}
%{ if register_monitoring }
bash /opt/system-init/register-monitoring.sh
%{endif}
Enter fullscreen mode Exit fullscreen mode

And when you supply this file as an argument for the EC2 instance resource in Terraform, you will use the templatefile() function to make the magic happen:

resource "aws_instance" "web" {
  ami = var.my_ami_id
  instance_type = var.instance_type
  ...
  user_data = templatefile("${path.module}/instance-init.tftpl", {
    system_hostname = var.system_hostname
    register_monitoring = var.add_to_monitoring
  })
  ...
}
Enter fullscreen mode Exit fullscreen mode

And of course, you can create a template from any file type. The only requirement here is that the template file must exist on the disk at the beginning of the Terraform execution.

Key takeaways

Terraform is far beyond the standard resource management operations. With the power of built-in functions, you can write more versatile code and reusable Terraform modules.

✅ Use conditional expressions with count and for_each meta-arguments, when the creation of a resource depends on some context or user inout.

✅ Take advantage of implicit types conversion when working with input variables and their values to keep your code cleaner.

✅ Embed YAML and JSON-based objects right into your Terraform code using built-in encoding functions.

✅ And when you need to pass some files to the managed service, you can treat them as templates and make them multipurpose.

Thank you for reading down to this point! 🤗

And if you have some favorite Terraform tricks — I would love to know!


Discussion (0)