Getting Started with Terraform
What is Infrastructure as Code? Infrastructure as Code (IaC) is a broad term used for the approach and tooling to manage infrastructure in scripts...
7 min read
Ryan Royals : May 27, 2024 3:06:45 PM
Howdy! In my previous article about Getting Started with Terraform I talked about what Terraform is, why it's neat-o, and a small example on getting started in Azure.
Now we are ready for the next steps, getting a Terraform Project ready to deploy to independent Dev/Test/Production environments, based on one set of Terraform code.
A final repo of code of what we are building is Available Here if you wish to skip to the good part, or get lost on the journey. But otherwise this should be beginner friendly, and ideally use the least amount of code possible, following the mantra keeping it DRY (Don't-Repeat-Yourself).
A quick revision: the main.tf
is where we are going to store the bit of Terraform code that does a thing. Terraform does not care about names of files, or even the boundary of files. When you run Terraform commands, they effectively merge all of the *.tf
files found in the directory, and builds its own Dependency Graph from there. We simply name these files as main.tf
and variables.tf
as it makes it easier for the human to find.
Terraform does not traverse up or down the directory tree looking for
*.tf
files.
Terraform also does not care about file names, only the.tf
file extension.
Variables.tf
and the variable
block within are new to this series of blog posts, but as decently simple as they do what they say on the tin; they allow you to pass information into a Terraform module based on Environment variables, command line inputs or passing a .tfvars
file.
Terraform has the concept of modules, which are close to fully fledged Terraform instances in of themselves, having their own Variables and Outputs to plug them into other modules. The top level code for Terraform is called the Root Module.
A variable
is where you get to enforce what type of information is being passed into the module, for use later in the project.
# variables.tf
variable "location" {
description = "The location/region where the resources will be created."
default = "Australia East"
type = string
nullable = false
}
variable "tags" {
description = "A object of tags to add to all resources."
type = object({
workload = string
owner = string
deployedBy = optional(string, "Terraform")
})
nullable = true
}
variable "naming" {
description = "A object of lists used to construct the names of resources."
type = object({
prefix = list(string)
suffix = list(string)
})
nullable = false
}
When declaring a variable
block, you also give it a label that is used later in the configuration to reference this variable, which in this example we have location, tags and naming.
Variable
blocks are a little strange in the way that you do not have to define anything else, and it would be usable from here. There is no use case for this configuration though, and I would enforce that description
and type
are the minimum requirements for a block.Description
is the descriptor for the variable, which is printed out to the CLI if no matching input is found, or used by tools like terraform-docs.Type
is used to define what input is expected. This can be any type that is supported by Terraform, which are categorized as primitive, collection, and structural types. These will be explored in the next section.Default
is the default value for the variable. Whatever defined here must be valid against the type
parameter.Nullable
Is the last parameter we will cover in this post. By default, variables
are nullable, which is Terraform for "Don't deploy anything", and is functionally different to a blank value like "". Although its not required, it is good practise to confirm if a variable
should be nullable
or not to avoid other users finding creative ways to break your deployment.
Default
and Optional()
are both ways to predefine a default option for a variable, but fill different needs. Default
is the default for the whole variable, and in the instance of a Collection or Structural type if you are to change anything based on user input, the whole default is overwritten.Optional()
is a default for a specific value on a key, so when using a Collection or Structure, the input can define other values without supplying the key that has the optional()
value, and the default supplied by the optional will be used.
Here is a quick run through of each type, and how you pass inputs to the variable
in a .tfvars
file. These types are also used within you Terraform code in blocks such as locals
, but those types are implied and assumed by Terraform, whilst in variables
they are explicitly called out.
Primitive Types are the most atomic unit of input, and are used as the building blocks for more complicated Collections and Structures.
string
A sequence of unicode characters. Used all the time in Terraform.
# variables.tf
variable "lettersandstuff" {
type = string
}
# dev.tfvars
lettersandstuff = "heyyyy"
number
A number, can be fractional. Used rarely in real deployments, as Terraform is Declarative and things like sums don't always find a home when we are trying to tell a cloud platform to look like X.
# variables.tf
variable "calc_u_later" {
type = number
}
# dev.tfvars
learning_something = true
Bool
A Boolean value (True or false). Used all the time for conditionals in deployments.
Collections are a collection group of the same types, used to collate information together. If you are looking to mix and match types under the same variable without nesting, that is the use case for a Structural Type.
list
A ordered sequence of primitive types, which can have duplicates. Used all the time as input as it is simple to work with when we start looking at things like Loops.
# variables.tf
variable "agoodlist" {
type = list(string)
}
variable "acomplicatedlist" {
type = list(object({
name = string
}))
}
# dev.tfvars
agoodlist = ["this", "blog", "is", "is", "is", "great"]
acomplicatedlist = [
{
name = "Great"
},
{
name = "Awesome"
}
]
map
A unordered collection of values identified by a Key. Not used much as its keys are not strictly set, and Terraform is very strict about knowing keys before Plan/Apply.
# variables.tf
variable "pirate" {
type = map(string)
}
# dev.tfvars
pirate = {
yarhar = "fiddle dee dee",
thisIsAKey = "thisIsAValue"
}
set
A unordered sequence of unique values. Very rarely used as a variable, as lists are easier to work with (Since Lists are ordered, Terraform interacts with them in a simpler fashion).
# variables.tf
variable "abigtruth" {
type = set(string)
}
# dev.tfvars
abigtruth = ["this","blog","just","keeps","giving"]
Structural types are similar to Collection types, but a singular Structure can have different Types all bundled together.
object
A collection of named attributes, each with their own type. Used all the time by itself as a variable
, or within a list
as you get to define the Key up front, and know exactly what type of value you are looking up.
# variables.tf
variable "aobject" {
type = object({
name = string
count = number
})
}
# dev.tfvars
aobject = {
name = "hellyeah"
count = 20
}
tuple
A ordered sequence of types. Each type declared must have exactly that. Hard to work with, not sure we have ever actually used it to have different types in any deployment. Note that tuple
and list
are both wrapped in square brackets []
, which can confuse Terraform sometimes.
# variables.tf
variable "tupleware" {
type = tuple([string, bool, number])
}
# dev.tfvars
tupleware = ["tupleCheck", false, 001]
any
TypeFor the sake of completeness, there is another type you can define being any
. This is the same as not declaring a type on the variable
block at all.
I will be stern and say there is no valid use case for this type, and it is not even worth looking for exceptions or examples on when its useful, as it is almost a fundamental breach of the declarative nature of Terraform.
# main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
}
}
backend "azurerm" {
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "rg" {
name = join("-", concat(var.naming.prefix, ["rg"], var.naming.suffix))
location = var.location
}
resource "azurerm_storage_account" "st" {
name = join("", concat(var.naming.prefix, ["st"], var.naming.suffix))
resource_group_name = azurerm_resource_group.st.name
location = var.location
account_tier = "Standard"
account_replication_type = "LRS"
}
This is the simple main.tf
we will use in the example to explain what is going on block by block:
terraform
and provider
blocks are the meta information for Terraform, telling it what Providers to download, and how to configure them. Each provider
represents a cloud environment, which in this case we are using our trusty azurerm
provider.
Notably in the terraform
, we have backend "azurerm"
defined but empty. This block is used to configure a Azure Storage Account as the state storage location, as shown in documentation here. We will be configuring this block by using the *.tfbackend
file later in the configuration.
Backend
andProvider
blocks authenticate separately which can be unintuitive when first trying to understand the Terraform configuration. The reasoning behind this is you could useazurerm
for your backend, but using aaws
provider.
The resource
blocks show how we actually call the variables, by using the var.
prefix and the label of the variable
declared earlier. As when calling resource
blocks if your variabe
unordered type, you can use dot notation to call the key (like azurerm.st.name
), otherwise you use square bracket notation to call element in a ordered list (like var.list[1]
).
The last step in configuring the files to be ready for different environments is to start loading values into the .tfvars
and .tfbackend
files. Luckily these files are very simple.
# dev.tfvars
tags = {
workload = "env-config"
owner = "someone"
deployedBy = "Terraform"
}
naming = {
prefix = ["dev"]
suffix = ["aue", "config"]
}
The .tfvars
file is super simple, and is just a collection of keys and values. Following the Input Variables Precedence documentation on the Terraform Docs, anything we pass here in this file will overwrite any Environment variables and defaults in the Terraform configuration.
The .tfbackend
is another simple file that follows the same structure as the .tfvars
file.
# dev.tfbackend
resource_group_name = "dev-rg-aue-tfstate"
storage_account_name = "devrgauetfstate123647"
container_name = "tfstate"
key = dev.tfstate"
When we pass this file later when we run terraform init
the content of this file gets overwrites whatever is configured in the backend "azurerm"
in the main.tf
file.
For the rest of the demo, it is assumed that the CLI you are using has Terraform installed, and AzCLI installed and logged into a subscription, and a Storage Account created with its details filled into the
.tfbackend
From here, its just simple parameters we pass to the Terraform CLI to get it to do the do.
Initialising the Project changes slightly as when you run init
, it configures the .terraform
folder to use the backend:
terraform init -backend-config='./variables/dev.tfbackend'
Once this has initialised, we are ready to use the dev
backend for our plan
and apply
:
terraform plan -var-file "./variables/dev.tfvars"
terraform apply -var-file "./variables/dev.tfvars"
When ready to move to another environment like uat
, the .terraform
needs to be reconfigured to point to the next state file:
terraform init -backend-config='./variables/uat.tfbackend' -reconfigure
The -reconfigure
switch tells Terraform to reconfigure the backend. The same effect can be done by deleting the .terraform
folder, but this keeps the downloaded providers and is overall neater.
With that, you should be ready to deploy to as many environments as you want, building upon Terraform configuration once and having it deployable to as many environments as you want.
We are now close to a template that is ready for real world use in deploying actual workloads to actual places. The last missing link is CI/CD pipelines and configuring other Identities (Like Managed Identities using OIDC) to do the doing.
What is Infrastructure as Code? Infrastructure as Code (IaC) is a broad term used for the approach and tooling to manage infrastructure in scripts...
An ambiguous name for a hard to track down issue! When you are doing a NSlookup for a blob endpoint on a Storage Account in a Virtual Network in...
As part of an engagement with a client, I had to write guidance around using Managed Identity when interacting directly with Azure REST APIs on Azure...