Terraform π»
Reference Resources
- Terraform best practices
- Best practices when using terraform
- Terraform documentation
- Terraform Up & Running
Linting
We use TFLint for automated code checks.
Formatting
- Always use terraform fmt to rewrite Terraform configuration files to a canonical format and style.
Naming
- Use
snake_case
in all: resource names, data source names, variable names, outputs. - Only use
lowercase letters and numbers
. - Always use
singular noun
for names. - Always use
double quotes
.
Resource and data source arguments
-
Do not repeat the resource type in the resource name (not partially, nor completely)
// Good resource "aws_route_table" "public" {} // Bad resource "aws_route_table" "public_route_table" {} // Bad resource "aws_route_table" "public_aws_route_table" {}
-
Resource name should be named
this
if there is no more descriptive and general name available, or if resource module creates single resource of this type (eg, there is single resource of typeaws_nat_gateway
and multiple resources of typeaws_route_table
, soaws_nat_gateway
should be namedthis
andaws_route_table
should have more descriptive names - likeprivate
,public
,database
). Normally, we usethis
to delegate theoutput
of amodule
with theresource
itself.# modules/nimble_alb/main.tf resource "aws_lb" "nimble" { name = var.load_balancer_name load_balancer_type = "application" security_groups = [var.vpc_security_group_ids] subnets = [var.subnets] } # modules/nimble_alb/outputs.tf output "this_dns" { description = "Nimble Application load balancer DNS" value = aws_lb.nimble.dns } # main.tf module "nimble_alb" { source = "./modules/nimble_alb" ... } # Get the DNS by `module.nimble_alb.this_dns`
-
Include
tags
argument, if supported by resource as the last real argument, following bydepends_on
andlifecycle
, if necessary. All of these should be separated by a single empty line.resource "aws_nat_gateway" "nimble" { count = "1" allocation_id = "..." subnet_id = "..." tags = { Name = "..." } depends_on = ["aws_internet_gateway.this"] lifecycle { create_before_destroy = true } }
-
Include
count
argument inside resource blocks as the first argument at the top and separate by newline after it.resource "aws_route_table" "public" { count = "2" vpc_id = "vpc-12345678" .... }
-
When making the condition in the
count argument
, use theboolean value
, if it makes sense, otherwise use thelength
method.# create_public_subnets: true count = var.create_public_subnets
or
count = length(var.public_subnets) > 0 ? 1 : 0
-
To make inverted conditions, use
1 - {boolean value}
.# create_public_subnets: true # 1 - true => false count = 1 - var.create_public_subnets
Variables
- Do not reinvent the wheel in resource modules - use the same variable names, description, and default as defined in the βArgument Referenceβ section for the resource you are working on.
- Omit
type = "string"
declaration if there isdefault = ""
also - Omit
type = "list"
declaration if there isdefault = []
also. - Omit
type = "map"
declaration if there isdefault = {}
also. - Use plural form in name of variables of type
list
andmap
. - When defining variables order the keys:
description
,type
,default
.
Outputs
Name for the outputs is important to make them consistent and understandable outside of its scope (when the user is using a module it should be obvious what type and attribute of the value is returned).
- The general recommendation for the names of outputs is that it should be descriptive for the value it contains and be less free-form than you would normally want.
-
If the output is returning a value with interpolation functions and multiple resources, the {name} and {type} there should be as generic as possible (this is often the most generic and should be preferred).
// Good output "this_security_group_id" { description = "The ID of the security group" value = element(concat(coalescelist(aws_security_group.this.*.id, aws_security_group.this_name_prefix.*.id), list("")), 0) } // Bad output "security_group_id" { description = "The ID of the security group" value = element(concat(coalescelist(aws_security_group.this.*.id, aws_security_group.web.*.id), list("")), 0) } output "another_security_group_id" { description = "The ID of web security group" value = element(concat(aws_security_group.web.*.id, list("")), 0) }
-
If the returned value is a list it should have a plural name.
output "this_rds_cluster_instance_endpoints" { description = "A list of all cluster instance endpoints" value = [aws_rds_cluster_instance.this.*.endpoint] }
Main File Structure
-
Group each component in the main file together:
# provider configuration # backend store for terraform # data source arguments # resource/module # resource `null_resource`
-
Order by
top
todown
in term of execution# Create VPC first resource "aws_vpc" "nimble" {} # Create IAM role before the EC2 instance resource "aws_iam_role" "nimble_web" {} # Create EC2 instance resource "aws_instance" "nimble_web" {}
Project Structure
It depends on the project, before start doing this, prefer to have an overview of the system, by checking
-
How complex is the infrastructure of the project?
- Which Terraform providers to use
- Number of related resources
- Number of Terraform providers
-
How environments are grouped?
- By environment, region, project
Typical structure for common providers
-
Heroku:
terraform-heroku-project ββ main.tf ββ outputs.tf ββ variables.tf β ββ staging.tfvars ββ production.tfvars β ββ README.md
- main.tf: Call modules, locals and data-sources to create all resources
- variables.tf: contains declarations of variables used in main.tf
- outputs.tf: contains outputs from the resources created in main.tf
-
IaaS (AWS, Google Cloud, Azure):
terraform-project ββ modules β ββ module_1 β β ββ main.tf β β ββ outputs.tf β β ββ variables.tf β ββ module_2 β β ββ main.tf β β ββ outputs.tf β β ββ variables.tf β ββ main.tf ββ outputs.tf ββ variables.tf β ββ staging.tfvars ββ production.tfvars β ββ README.md
- module: Module is a collection of connected resources which together perform the common action (eg: aws_vpc creates VPC, subnets, NAT, etc)
- main.tf: Call modules, locals and data-sources to create all resources
- variables.tf: contains declarations of variables used in main.tf
- outputs.tf: contains outputs from the resources created in main.tf
- staging.tfvars: Configuration for Staging (like instance type,β¦)
- production.tfvars: Configuration for Production (like instance type,β¦)
Best Practices
Use shared modules
-
Manage Terraform Resources with shared modules, this will save a lot of coding time. No need re-invent the wheel!
-
Always set the
module version
.// Good module "rds" { source = "terraform-aws-modules/rds/aws" version = "2.18.0" ... } // Bad module "rds" { source = "terraform-aws-modules/rds/aws" ... }
State store
Always using Remote State
-
Start your project with Terraform Cloud Remote State Management π―
terraform { cloud { organization = "nimble" workspaces { name = "overblock" } } }
-
Start your project with AWS S3 π―
terraform { backend "s3" { region = "aws_region" # This bucket needs to be created beforehand bucket = "bucket_name" key = "aws-setup/state.tfstate" encrypt = true # AES-256 encryption } }
Security
- Using backend S3, set IAM policy to limit the access to that bucket.
- Do not hardcode sensitive data (password, ssh key,β¦) in the Terraform code or in
variables.tf
files, do not push it to Git, define sensitive data on the fly (terraform plan
orterraform apply
) or Terraform Cloud.
AWS User data (The user data to provide when launching the instance)
- Prefer to use cloud-init over
bash script
.