10 Terraform Best Practices

10 Terraform Best Practices

1. Use modules (DRY code)

Don't Repeat Yourself (DRY) is one of the software development principles. This principle aims to avoid redundancy in the code declaration.

Whenever you find yourself writing the same terraform code more than once, use modules.

Modules are containers that hold resources that are used together.

Read more here. https://www.terraform.io/language/modules/develop

# using the module server_deployment_module and declaring some variables
module "server_deployment" {
  source          = "github.com/myuser/server_deployment_module"
  # module variables
  server_name     = "storage-west"
  server_size     = "c5.xlarge"
  server_username = "diego"
}

2. Learn how to use locals

Learning how to use terraform locals is essential for mastering terraform. In a nutshell locals let you manipulate data at runtime. Many people confuse variables with locals, but they are not the same

variables: They are given as input and are not mutable

variable "first_name" {
  type     = string
  default  = "John"
}

variable "last_name" {
  type    = string
  default = "Doe"
}

locals: They are set at runtime and are mutable. You can manipulate data from variables and resources created at runtime, so they are pretty powerful. Learn how to use them.

locals {
  full_name = join(" ", [var.first_name, var.last_name])
}
# local.full_name = "John Doe"

3. Make use of backends

Terraform has a file called terraform.tfstate where it saves all the deployment configurations done to the target resources. By default, it will save the file in your local filesystem, but having it in your local machine is a complete mistake, and here is why:

  • If something happens to your storage, you’re pretty much screwed

  • If you work with a distributed team, they won’t have access to the file (unless you hand it to them)

  • It is not versioned, so you won’t know past states (useful for debugging and compliance)

⚠️ The backend is only declared in the main.tf file, not in a module

# Sample backend using S3 buckets. 
# Supported backends here https://www.terraform.io/language/settings/backendsterraform {
backend "s3" {
  bucket  = "my-s3-bucket"
  key     = "production/terraform.tfstate"
  encrypt = true
  region  = "us-east-1"
}

4. Layout your project properly

There is a standard layout for terraform modules that you can use for pretty much all your projects, here it is.

├── backend.tf # not needed if it's a module
├── template_files
│   ├── user_data.sh
│   └── config_file.conf
├── README.md
├── main.tf
├── outputs.tf
├── variables.tf
├── versions.tf

As you can see is pretty easy to figure out where each resource is just by looking at the tree structure. We want this to be simple to understand because other people might work on this code in the future.

5. Encrypt your sensitive variables

Do you want to share your terraform with a colleage but don’t want to expose the secrets you may have? sops might be what you are looking for.With it, you can commit your terraform with an encrypted file containing your secrets (this is much better than hardcoding them into your variables file). Here is a nice provider I strongly recommend for using sops with terraform

terraform {
  required_providers {
    sops = {
      source = "carlpett/sops"
      version = "~> 0.5"
    }
  }
}

data "sops_file" "demo-secret" {
  source_file = "demo-secret.json"
}

output "root-value-password" {
  # Access the password variable from the map
  value = data.sops_file.demo-secret.data["password"]
}

💡 Sops is just for sharing secrets used by terraform. If you want to store infrastructure secrets you might want to look at Vault, AWS Secrets Manager or K8s Secrets

6. Specify variable and output types

I hate when I have to figure out the datatype expected by a variable, especially when it’s a map or a complex object.

Specifying the data type of variables has benefits

  • Makes your terraform code easier to work with

  • When specified, terraform enforces that type, so it will error out if another type is given

  • For complex objects, it allows you to understand the data better.

variable "docker_ports" {
  type = list(object({
    internal = number
    external = number
    protocol = string
  }))
  default = [
    {
      internal = 8300
      external = 8300
      protocol = "tcp"
    }
  ]
}

Just by looking above I can tell that the docker_ports variable is expecting a list of maps containing internal, external and protocol values, simple. (The more you use them, the better you get at reading them)

Also note that if at runtime I enter another property inside the map like dummy

 {
  internal = 8300
  external = 8300
  dummy    = "im a dummy key"
  protocol = "tcp"
}

dummy field won’t get passed, I’d still see the 3 keys defined in the docker_ports variable. This is great to avoid unwanted fields.

7. Use terraform built-in functions

Terraform has many powerful built-in functions, use them alongside your locals or inside your resources. https://www.terraform.io/language/functions

There are functions for many things. Strings, maps, objects, date and time, filesystem, cryptography, data manipulation, networking and many more

# Sample lower function
lower("HELLO")
hello

lower("АЛЛО!")
алло!

8. Specify terraform, provider and module versions

Terraform modules change regularly, as well as terraform providers. This is why you should always version your providers as well as your modules

Versioning module

# Use branch or tag v1.0.0 from github repo
module "server_deployment" {
  source          = "github.com/myuser/server_deployment_module?ref=v1.0.0"
  # module variables
  server_name     = "storage-west"
  server_size     = "c5.xlarge"
  server_username = "diego"
}

This will ensure we use a working version of this module at any point in time

Versioning terraform and provider

# versions.tf file
terraform {
  required_version = "1.2.0"  
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

By versioning your provider and your terraform version we make sure any person using this in the future will use working versions we used to deploy this.

9. Document your code!

I know, I know, you hate documentation, but I have news for you… You can automate this! Check out terraform docs, the only thing you need to do is follow the steps I mentioned you above for best practices like specifying variable and output types as well as defining your versions. terraform-docs will then read all those settings and write professional-looking markdown readme for you, its pretty cool!

# This will print the generated readme
# in the shell in markdown format
$ terraform-docs markdown .

(Advanced) You can also automate the documentation creation with Github Actions https://github.com/terraform-docs/terraform-docs#using-github-actions

10. Learn how to navigate Terraform docs and registry

A lot of times you will find yourself reading how to do things in terraform, and turns out someone else already did it for you, even terraform themselves!

Before trying to reinvent the wheel, take a look at the docs and the registry, maybe someone already made a module for whatever you want to build. Or maybe terraform has the function you need to make your locals work (trust me, I’ve been there many times).

Besides, they have pretty great docs, I have to admit https://www.terraform.io/language.

11. (Bonus) Format your code

You don’t have excuse for this one, just run terraform fmt --recursive and see what happens... You can thank me later.

I hope this has helped you writing better terraform code!

If you have any other tip in mind, please share in the comments section below.