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.tffile defines the module source and theenvironmentvariable - Example from the
prodenvironment:
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.hclfile 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.hclis the following
terraform {
source = "../../../modules/ec2-instance"
}
inputs = {
instance_type = "t2.micro"
instance_name = "example-server-dev"
}
Best practices
- This section is based from the google guide for best practices
Module structure
-
Mandatory files:
main.tfREADME.md
-
Create groupings of resources:
network.tfloadbalancer.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.tffile - 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.tffile - 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.tffile
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
countmeta-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_eachmeta-argument