Terraform and Best practices

Terraform

Setup

  • Create the basic folders
mkdir -p terraform/{modules,environments/{dev,prod}}
touch terraform/modules/main.tf
touch terraform/environments/{dev,prod}/{main,backend}.tf

Environments

  • The main.tf file defines the module source and the environment variable
  • Example from the prod environment:
module "prod" {
  source      = "../../modules"
  environment = "prod"
}

Backend

  • Each backend is where terraform is saving it's state
  • The default bucket must be create before by hand on the console
  • One the bucket is created, we need to edit the bucket policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "<your_user_arn>"
            },
            "Action": "s3:ListBucket",
            "Resource": "<your_bucket_arn>"
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "<your_user_arn>"
            },
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource": "<your_bucket_arn>/*"
        }
    ]
}
  • Once this is done, we can complete the backend.tf
terraform {
  backend "s3" {
    bucket         = "<bucket-name>"       # Bucket name and not arn
    key            = "terraform.tfstate"
    region         = "<region>"            # Should be the default region
  }
}

Provider

  • We need to tell terraform which cloud provider to use
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

## Configure the AWS Provider
provider "aws" {
  region = "us-east-1"
}
  • We can use some variables to clean the code a bit
variable "region" {
  default = "us-east-1"
}

provider "aws" {
  region = var.region
}

Plan and Apply

  • Now that everything is configure, we can save the sate to the bucket
terraform plan
terraform apply

Terragrunt

  • Terraform is good but not perfect if we want to have multiple environments with different cloud infrastruture
  • Terragrunt is here to provide a clean and DRY way to fix this
  • Information taken from this excellent source

Resource variables

  • Let say we want to have a simple EC2
resource "aws_instance" "example" {
  ami           = "ami-0fb653ca2d3203ac1"
  instance_type = "t2.micro"
  tags = {
    Name = "example-server"
  }
}
  • To use this code with Terragrunt, we need to convert this code into a module with variables that are different for each environment
variable "instance_type" {
  description = "The instance type to use"
  type        = string
}
variable "instance_name" {
  description = "The name to use for the instance"
  type        = string
}
  • Now the EC2 code is using variables
resource "aws_instance" "example" {
  ami           = "ami-0fb653ca2d3203ac1"
  instance_type = var.instance_type
  tags = {
    Name = var.instance_name
  }
}
  • The final folder structure should look like this
.
└── modules
    └── ec2-instance
        ├── main.tf
        ├── outputs.tf
        └── variables.tf

terragrunt.hcl

  • The terragrunt.hcl file is where you define which module is deploy and what the inputs are
  • A proper environement folder with terragrunt looks like this
.
├── live
│   └── dev
│       └── ec2-instance
│           └── terragrunt.hcl
└── modules
    └── ec2-instance
        ├── main.tf
        ├── outputs.tf
        └── variables.tf
  • The content of that terragrunt.hcl is the following
terraform {
  source = "../../../modules/ec2-instance"
}
inputs = {
  instance_type = "t2.micro"
  instance_name = "example-server-dev"
}

Best practices

Module structure

  • Mandatory files:

    • main.tf
    • README.md
  • Create groupings of resources:

    • network.tf
    • loadbalancer.tf
    • ...
  • The minimal structure is:

.
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf

Naming convention

  • Use underscore _ to delimite multiple words
  • This does not apply to name arguments
resource "google_compute_instance" "web_server" {
  name = "web-server"
}
  • Make resource names singular
  • Don't repeat the resource type in the name

Good

resource "google_compute_global_address" "main" { ... }

Bad

resource "google_compute_global_address" "main_global_address" { … }

Variables

  • Declare all variables in the variables.tf file
  • Give them defined types
  • Use empty defaults for variables only if an empty value doesn't break the system

Outputs

  • Declare all outputs in the outputs.tf file
  • Don't pass outputs directly through input variables

Good

output "name" {
  description = "Name of instance"
  value       = google_compute_instance.main.name
}

Bad

output "name" {
  description = "Name of instance"
  value       = var.name
}

Data sources

  • Put data sources next to the resource that reference them
  • If the number of data sources is getting to big, consider adding them to the data.tf file

Custom scripts

  • Only use custom scripts when necessary
  • Terraform can call custom scripts through provisioners, including the local-exec provisioner
  • Custom scripts are placed in the scripts/ directory

Count

  • If you want a resource to be instantiate conditionally, use the count meta-argument
variable "readers" {
  description = "..."
  type        = list
  default     = []
}

resource "resource_type" "reference_name" {
  // Do not create this resource if the list of readers is empty.
  count = length(var.readers) == 0 ? 0 : 1
  ...
}

For each

  • If you want to create multiple copies of a ressource based on the input, use the for_each meta-argument