Terraform Configuration Syntax Overview

Written by letsdotech | Published 2021/01/19
Tech Story Tags: terraform | coding | aws | cloud | infrastructure | infrastructure-as-code | devops | toolchain

TLDR This post is intended to give a brief overview of the configuration syntax of Terraform. It is assumed that Terraform is installed on your local system, you have access to the AWS management console, and have set up and configured an IAM user for Terraform. We would go through an example and touch up on some of the important aspects of the Terraform configuration language, to successfully create an IaC and see that in action. This by no means is an attempt to rewrite all the technical details available in Terraform docs.via the TL;DR App

This post is intended to give a brief overview of the configuration syntax of Terraform. We would go through an example and touch up on some of the important aspects of Terraform configuration language, to successfully create an IaC and see that in action. This by no means is an attempt to rewrite all the technical details available in Terraform docs, however, if you want to get up and running with Terraform, this is the right place to get the direction.
Note: As a prerequisite, it is assumed that Terraform is installed on your local system, you have access to the AWS management console, and have set up and configured an IAM user for Terraform.

Arguments and Blocks

Referring to an example in the previous post, we created an EC2 instance with below code.
provider “aws” {
  region = “us-west-1”
}

resource “aws_instance” “myec2” {
  ami = “ami-12345qwert”
  instance_type = “t2.micro”
}
The code consists of 2 blocks wrapped in curly braces (
{}
), and each of these blocks has certain arguments defined. Just like most programming languages, arguments are used to assign values to variables. In Terraform configuration language, these variables are attributes associated with a particular type of block.
provider "aws"
block has one argument - "
region = "us-west-1"
", where the
region
is an attribute associated with the block, and it is assigned a value
"us-west-1"
.
The value is of the type string, thus it is enclosed in a pair of double quotes (
""
). Similarly, the resource block has 2 arguments that set the values of associated attributes.
Terraform configuration language makes use of various types of blocks. Based on the type, blocks represent and enclose a set of attributes and functions. In the given example, we have a block of type
provider
and another of type
resource
.
Terraform makes use of certain types of blocks (
provider
and
resource
, in the example), and each block has its identifier and a set of input labels. The provider block takes one input label - that is the name of the provider. In this case "
aws
".
It also informs Terraform to install aws provider plugin, during
init
phase. Resource block takes 2 inputs labels - the type of resource and the name of the resource. In this case - the type is "
aws_instance
" and the name is "myec2". What follows is the block body enclosed in curly braces.

Where to start?

So, how do we start expressing our infrastructure as code and make use of it? Let us take an example of creating a simple EC2 instance on AWS. Let us start by creating a directory of your choice where you would place all the configuration code required to create an EC2 instance.
By default, Terraform assumes that all the files with
.tf*
extensions in a directory are part of the configuration, irrespective of the file names. Create a file by name main.tf in this directory.
Note: The code used in this example can be referred from this commit on Github.
The very first thing which we need to declare is — which providers are we going to use? Since we are going to spin an EC2 instance on AWS, we declare the same as below.
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region = “us-west-1”
}
We have declared 2 blocks —
terraform
and
provider
.
terraform
is a top-most block, but it is optional as well. It is a good practice to specify this, especially when we work with remote state management. We will talk about remote state management in upcoming posts.
terraform
block has a nested block that specifies
required_providers
. We require
aws provider
.
aws
within
required_providers
is a map, which specifies the
source
and
version
of the
provider
.
Next, we have a
provider
block for
aws
, which specifies the
region
.
Generally, this is how every Terraform code would start. Of course, there would be some variations, and the best way to be sure about it is to refer to the Terraform registry for specific versions of Terraform as well as the provider plugin itself.
For the sake of the current example, we are referring to AWS plugin documentation. The Terraform registry documents usage of all the resources of various cloud providers with example and it is a great resource for Terraform reference.

Providers

Installing Terraform on the system is not enough. To make configurations work, Terraform makes use of provider plugins. These plugins are installed in the initialization phase. Provider plugins come with their own set of configurations, resource types, and data sources.
Terraform registry documents all the details for a given provider.

Resources

Every provider comes with a set of resources.
resource
, as the name suggests, represents the actual cloud resource to be created in the configuration language. Providers enable resources. In the given example,
aws
is a provider and
aws_instance
is a resource provided by the AWS provider. The resource has its attributes.
These attributes are documented on the Terraform registry. Out of all the attributes, some of the attributes are required for the Terraform to be able to process the configuration. Resources are the exact constructs that are executed by Terraform.
Continuing with the example, let us define an AWS EC2 instance resource by appending the below code into our main.tf file.
resource "aws_instance" "demo" {
 ami = “ami-00831fc7c1e3ddc60”
 instance_type = “t2.micro”
 
 tags = {
   name = "Demo System"
 }
}
We start with a resource block named “
aws_instance
" and we pass a second label and name it as "
demo
". The second label is the name of your choice. Next, open the block using curly braces and specify the required attributes used by the resource
aws_instance
. The first attribute is
ami
which specifies the Amazon machine image ID for the EC2 instance.
The second attribute is the
instance_type
which specifies the size of the machine to be created. We are also passing
tags
which is an optional argument. As a tag, we pass "name" in the key and "Demo System" in the value. That's it we have defined our
resource
.
We are now technically ready with the configuration and we can go ahead and initialize the Terraform into this directory so that it installs the provider plugin for AWS and we can then plan and apply this configuration.
Save the file, go ahead and run
terraform init
and see if it installs AWS provider plug-in. Once that is done successfully run
terraform plan
and observe the output.
Let us put everything into perspective —
providers
let the Terraform know which plugins need to be installed to execute the configuration.
resources
represent the actual cloud resources to be created.
Generally, every resource has a name ("
aws_instance
"). The initial part of the name of the resource is the provider identifier ("
aws
") which is separated by an underscore.

Variables

By now, we know that Terraform is a declarative language. In the example, we have declared the final state of a desired virtual machine on the desired cloud. Now it is up to Terraform to take this configuration and execute it to create the virtual resource. Having said that Terraform gives us the ability to specify input variables to its configuration.
Input variables are like parameters for a given function just like in any programming language.
It is particularly useful when you have to specify the same value at multiple places in your code. As the project grows in size, it becomes easier to change certain values that might be used in multiple places, using variables.
Terraform supports primitive types of variables such as string, number, boolean, and several complex types such as list, set, map, object, and tuple.
Let us define some variables into our code as below:
variable "region" {
 default = "us-west-1"
 description = "AWS Region"
}
 
variable "ami" {
 default = "ami-00831fc7c1e3ddc60"
 description = "Amazon Machine Image ID for Ubuntu Server 20.04"
}
 
variable "type" {
 default = "t2.micro"
 description = "Size of VM"
}
As you can see we have introduced three new
variables
for the
region
, the
ami
, and the
type
. Let us use this in our configuration so far. The values of the variables can be referred to using
var
.<variable name>.
Terraform configuration also gives us the ability to return values. These values are known as output values. When Terraform completes the execution of the configuration, the output values are made available which can be used as input to other interfaces. We have defined one output variable “
instance_id
" into our code.
The value of this output variable is set using attribute reference of "
aws_instance.demo
". Similarly, we can refer to other output variables available from any resource in the configuration.
Below is the updated code of our main.tf.
We have made use of three variables at appropriate places.
terraform {
 required_providers {
   aws = {
     source  = "hashicorp/aws"
     version = "~> 3.0"
   }
 }
}
  
provider "aws" {
 region = var.region
}
  
variable "region" {
 default = "us-west-1"
 description = "AWS Region"
}
  
variable "ami" {
 default = "ami-00831fc7c1e3ddc60"
 description = "Amazon Machine Image ID for Ubuntu Server 20.04"
}
  
variable "type" {
 default = "t2.micro"
 description = "Size of VM"
}
  
resource "aws_instance" "demo" {
 ami = var.ami
 instance_type = var.type
  
 tags = {
   name = "Demo System"
 }
}
  
output "instance_id" {
 instance = aws_instance.demo.id
}
Save the file and run
terraform plan
. Notice that Terraform has taken note of the output variable this time. It states that the output is known after apply, which is kind of obvious.
Plan: 1 to add, 0 to change, 0 to destroy.
  
Changes to Outputs:
  + instance_id = (known after apply)
Go ahead and do
terraform apply
, and let me know the output.
Don't forget to run
terraform destroy
after every successful apply.
Lastly, Terraform also supports local variables, which are temporary values used locally by functions and blocks.

Provisioners

So before we conclude this part, let’s take a look at provisioners for a while. Provisioning means to install, update and maintain required software once the hardware or virtual machine is successfully made ready. Terraform can trigger software provisioning processes once a virtual machine is ready, but that does not mean it is a full-time provisioning tool.
This ability of Terraform can be used to make the infrastructure ready for management by installing smaller but essential software components.
There exist tools like Salt Stack, Ansible, Chef, etc., and most of these tools are agent-based. Terraform ability to run initial scripts to install some patch updates, agent software, or even set some user access policies to make sure machines are ready to be used.
Terraform comes bundled with generic provisioners as well as it supports vendor-specific provisioners. This is a topic for a future post.
Before we proceed, let us first organize our code into multiple files. As a general practice, the Terraform codebase is divided into multiple files based on the providers, resources, and variables. Let us create 3 files as below:
  1. variables.tf
    – This file would contain all the declared input variables. In our example, we have input variables defined for region, ami, and type and output variable instance_id.
  2. provider.tf
    – This file would contain declarations for providers being used. In our case, we have terraform, and the provider aws blocks.
  3. main.tf
    – This file would contain the declarations for actual resources to be created.
Refer to this commit on Github repository.
By default, Terraform assumes all the code placed in a particular directory as part of the same configuration. So technically it doesn’t make much of a difference if you put the code in a single file or divide it into multiple files and sub-directories. From the maintainability point of view, it makes a lot of sense to do so.

Meta-Arguments

Note: While working through the examples, please make sure to run “
terraform destroy
” after every
terraform apply
run.
Meta-arguments are special constructs provided for
resources
. We have seen that resource blocks are the actual cloud resources that are created by Terraform. Often, it becomes tricky to declare resources in a way that satisfies certain requirements.
Meta-arguments come in handy in situations like creating resources in the same cloud provider but in different
regions
, or when we are creating multiple identical resources with different names, or when we have to declare implicit dependencies at places where Terraform is not able to identify the dependency itself.
There aren’t many but a few meta-arguments available currently. They are as follows:
PROVIDER:
The provider meta-argument is used when we have multiple provider configurations in a given Terraform config. Terraform automatically maps the given resource to the default provider identified by the resource’s identifier.
For example, the default provider for “
aws_instance
” is “
aws
”. This
aws
provider is currently configured to deploy a resource in a particular region. However, if we would want to have another
aws
provider for another region, or with a different configuration setting, we can write another provider block.
Even though it is possible to write multiple provider configs, Terraform by default would pick the same provider for aws for creating resources. This is where aliases come into the picture. Every provider configuration can be tagged with an alias and the value of this alias is used in our provider meta-argument in the resource block to specify different provider configurations for identical resources.
In the given example, let us duplicate the aws provider and give them appropriate aliases. Modified providers with an
alias
should look like below in
provider.tf
file.
provider “aws” {
 alias = “aws_west”
 region = var.region_west
}
 
provider “aws” {
 alias = “aws_east”
 region = var.region_east
}
Notice that, we have also modified variables for the region to represent 2 different regions — west and east. Do the corresponding changes to
variables.tf
file as below:
variable "region_west" {
  default     = "us-west-1"
  description = "AWS West Region"
}
 
variable "region_east" {
  default     = "us-east-1"
  description = "AWS East Region"
}
One final change that we need to do is in the
main.tf
file. Where we can now use provider meta-argument to specify a specific provider alias. We can mention the desired provider config by specifying
<provider>.<alias>
in the meta-argument.
Refer to the modified
main.tf
file below:
resource "aws_instance" "demo" {
  provider      = aws.aws_west
  ami           = var.ami
  instance_type = var.type
 
  tags = {
    name = "Demo System"
  }
}
Validate the final configuration by running `terraform validate`, and it should say “
Success!
LIFECYCLE
The
lifecycle
meta-argument specifies the settings related to the
lifecycle
of resources managed by Terraform. By default, whenever a configuration is changed and applied, Terraform operates in the sequence below.
  1. Create new resources.
  2. Destroy those resources which do not exist in config anymore.
  3. Update those resources which can be updated without destruction.
  4. Destroy and re-create change resources that cannot be changed on the fly.
A
lifecycle
meta-argument can be used if we would like to alter this default behavior. These meta-arguments are used in resource blocks similar to provider meta-argument. There are 3 lifecycle meta-argument settings:
  1. create_before_destroy
    : Used when we want to avoid accidental loss of infrastructure when a changed config is applied. This setting when set to true, Terraform will first create the new resource before destroying the older resource.
  2. prevent_destroy
    : When set to true, any attempt to destroy this in the config would result in an error. This is often useful in the case of those resources where reproduction can prove to be expensive.
  3. ignore_changes
    : This is a list typed meta-argument which specifies the attributes of a specific resource in the form of a list. During the update operations, often there is a situation where we would like to prevent changes caused by external factors. In those cases, it becomes essential to declare the list of attributes that should not be changed without being reviewed.
lifecycle
meta-arguments come in very handy when we are in the process of setting up complex infrastructure. By altering the default behavior of Terraform, we can put some protection in the form of
lifecycle
meta-arguments for confirmed and finalized resource blocks. In our example, we would not use any lifecycle meta-argument.
DEPENDS_ON
Generally, Terraform is aware of dependencies while performing the creation or modification of resources and takes care of the sequence by itself. However, in certain cases Terraform cannot deduce the implicit dependencies and just moves on creating the resources parallelly if it doesn’t see any dependency.
Let us take, for example, a Terraform configuration for 2 EC2 instances enclosed in a VPC. When this configuration is applied, Terraform automatically knows that the creation of VPC should be done before spinning the EC2 instances.
This is general knowledge and Terraform knows it very well. In situations where dependencies are not so obvious, the
depends_on
meta-argument comes to the rescue. It is a list type of argument that takes in the list of resource identifiers declared in the configuration.
COUNT
Imagine a situation where you would like to create multiple similar resources. By default, Terraform creates one real resource for a single resource block. But in the case of multiple resources, Terraform provides a meta-argument named count. As the name suggests, the count can be assigned with a whole number, to represent multiple resources.
In our example, let us create 3 similar EC2 instances. Into your
main.tf
file, add an attribute count to the resource
aws_instance.demo
, and assign it with a value of 3. It should look something like the below.
resource "aws_instance" "demo" {
  count         = 3
  provider      = aws.aws_west
  ami           = var.ami
  instance_type = var.type
 
  tags = {
    name = "Demo System"
  }
}
By doing this, we let Terraform know that we need to create 3 EC2 instances with the same configuration. Save the file and execute
terraform validate
. It throws an error saying “
Missing resource instance key
”. Remember in our
variables.tf
file we have mentioned an output variable to output the
id
of the created resource. Since we have asked Terraform to create 3 instances, it is not very clear – ID of which of the 3 instances should be printed?
To get around this problem, we would use a special expression called “
splat
” expression. The ideal case here would be to run a for loop over the instance set and print out the ID property. Splat expression is a better way to do the same task with lesser lines of code. All you need to do is – in the
variables.tf
file, replace the output value code to below:
output "instance_id" {
  value = aws_instance.demo[*].id
}
Save this file and run
terraform validate
to see if everything is okay. Once successful, go ahead and run
terraform plan
and
apply
and check your AWS management console in us-west-1 region a.k.a
aws_west
.
Let me know the IDs too.
Splat expression is one of its kind and we would take a better look at expressions in upcoming sections.
FOR_EACH
for_each
, as the name suggests, is essentially a “for each” loop.
for_each
meta-argument is used to create multiple similar cloud resources. Yes, it does sound similar to count meta-argument but there is a difference.
Firstly,
for_each
and
count
cannot be used together.
Secondly, you can say this is an enhanced version of the
count
. Count meta-argument is a number type. Terraform simply creates those many resources. However, if you would like to create these resources with some customizations in the output, or if you already have an object of type map or list based on which you want to create resources, then
for_each
meta-argument is the way to go.
As mentioned earlier,
for_each
can be assigned a map and list type of values. A map is a collection of key-value pairs, whereas a list is a collection of values (in this case string values).
for_each
comes with a special object “each”. This is the iterator in the loop which can be used to refer to the
key
or
value
, or only key in case of list. Let us take a look at our example. We would like to create EC2 instances for the given map.
The map is assigned to
for_each
meta-argument and Terraform creates an EC2 instance for each key-value pair in the map. Lastly, we use the
key
and
value
information using
each
object to set the name attribute in the
tag
.
The resource block in `main.tf` now looks something like this.
resource "aws_instance" "demo" {
  for_each = {
    fruit = "apple"
    vehicle = "car"
    continent = "Europe"
  }
  provider      = aws.aws_west
  ami           = var.ami
  instance_type = var.type
 
  tags = {
    name = "${each.key}: ${each.value}"
  }
}
Execute
terraform validate
and observe the output. It throws an error for the output variable – “
This object does not have an attribute named id
”. A quick note here –
splat
expressions work for the list type of variables.
Since we have used map while setting our
for_each
meta-argument, we need to change the return value expression to for each, as below:
output "instance_id" {
  //value = aws_instance.demo[*].id
  value = [for b in aws_instance.demo : b.id]
}
Execute
terraform validate
again, if successful, go ahead and
apply
the configuration. Check the AWS management console for the machines created and the names assigned to them.

Expressions

Expressions are ways to make the Terraform code dynamic. Expressions come in 2 forms, simple and complex. Till now in our examples, we have mostly dealt with simple expressions.
A simple expression is any argument used as part of some block. Writing down an argument with a primitive value assigned to is a form of expression.
We have made use of a complex expression called splat (
*
) in our example while working with meta-arguments. However, there are even more complex expressions that can be used to make the Terraform code more dynamic, readable, and flexible.
There are various types of expressions that you can take a look at in the Terraform documentation.

Functions

Terraform has built-in functions that can be used with expressions. These are utility functions that are useful in number and string manipulations. There are functions to work with file systems, date and time, network, type conversion, etc.
Functions along with expressions make it super easy to write a really dynamic IaC. You can refer to the list of functions here.
This brings us to the end of Terraform Syntax. Next, we would take a look at Terraform CLI.
Originally published at http://letsdotech.dev on January 4, 2021.

Written by letsdotech | Technology generalist. I write about my experiences with tools and technologies. Current topic: Hashicorp Terraform
Published by HackerNoon on 2021/01/19