The DevOps Jedi

Taking the cloud by storm one line of code at a time....

What I Include In Every Terraform Configuration

2023-01-084 min readDarren Johnson

Terraform Required Providers & Versions

HashiCorp have adopted semantic versioning for both Terraform and the providers it uses. The versions are listed in the MAJOR.MINOR.PATCH format meaning the following apply:

  • MAJOR - may include incompatible breaking changes, such as changes to syntax used
  • MINOR - adds functionality and bug fixes in a backwards compatible manner
  • PATCH - adds backwards compatible bug fixes

When running a terraform init command, if no terraform block is present, terraform will use whatever version is installed locally on your machine and pull down the latest version of the provider(s) defined in the resources. For example, if your resource is named azurerm_resource_group then the provider must be azurerm so the latest version will be pulled down locally to your machine.

Given the fast pace of change with cloud, providers are frequently updated, and bugs do get introduced. Occasionally you may need to revert back to an earlier provider whilst a bug is being fixed, or even a different version of the Terraform binary if something has really got messed up, although this is much less likely to occur.

Controlling the version is simple with the terraform block. You simply state the source of the provider(s) you wish to use, and the version of terraform required and you’re done. If you want to adopt a ‘get current and stay current’ approach, which is what I recommend, you can use the ‘pessimistic constraint operator’ ~> in front of the current version number which allows the rightmost number to increment.

In the example below the configuration will support terraform versions from 1.3 and greater but not 2.x.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.40"
    }
  }
  required_version = "~> 1.3"
}

Azurerm Provider Features

In addition to the version control shown above, I also control the provider behaviour with a dedicated provider block. In this example I prevent resource groups from being deleted if resources still exist within them. This can happen when resources are created outside of the terraform configuration and are not present in state.

provider "azurerm" {
  features {
    resource_group {
      prevent_deletion_if_contains_resources = false
    }
  }
}

For more information on the azurerm features block, checkout the documentation .

Backend

Terraform uses the construct of state to map real world resources to your configuration . This is a subject for another post, but for now be aware that state is key and that a state file must be stored securely.

By default, state is stored locally on the machine the terraform configuration is being executed from which is less than ideal. State should be stored securely somewhere central that is accessible from any device that needs to execute the configuration. I’m using Azure, so naturally chose to store my state in a storage account.

The block is made up of 3 arguments:

  • key - this is the unique name of the state file that will be stored in the storage account
  • storage_account_name - the name of the storage account that will store the state file
  • container_name - the name of the blob container the state file will live in (think of this as a folder)
terraform {
  backend "azurerm" {
    key                  = "service-environment.terraform.tfstate"
    storage_account_name = "mystorageaccount"
    container_name       = "myblobname"
  }
}

This example is known as a partial configuration, because it doesn’t include all the arguments required to access the state file. In order to authenticate to the storage account another argument named access_key is required which is the secret needed to complete the authentication process. This argument is retrieved from an environment variable named ARM_ACCESS_KEY which is loaded as part of the initialisation process.

Variables

I use variables to ensure resources are configured in a consistent manner whilst retaining the benefits of a declarative configuration. Examples of arguments I always specify as variables are location & tags.

variable "location" {
  type    = string
  default = "westeurope"
}

Location is a single string with a default value set that can be reused within a configuration simply by specifying var.location.

variable "tags" {
  type = map(any)
  default = {
    service           = "blog"
    environment       = "msdn"
  }
}

Tags is a map of values identified by key/value pairs specified in the format of key = "value". Once set all the values can be applied at once by specifying var.tags which is a great way to ensure consistency in tags across all resources you wish to build.

Key Takeaways: Control the version of Terraform, and the providers in use. Use a partial configuration for remote state and use variables where appropriate.