Terraform πŸ’»

Hero image for Terraform πŸ’»

Reference Resources

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 type aws_nat_gateway and multiple resources of typeaws_route_table, so aws_nat_gateway should be named this and aws_route_table should have more descriptive names - like private, public, database). Normally, we use this to delegate the output of a module with the resource 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 by depends_on and lifecycle, 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 the boolean value, if it makes sense, otherwise use the length 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 is default = "" also
  • Omit type = "list" declaration if there is default = [] also.
  • Omit type = "map" declaration if there is default = {} also.
  • Use plural form in name of variables of type list and map.
  • 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 to down 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,…)

It is easier and faster to work with a smaller number of resources (in the main file also in the module as well), terraform plan and terraform apply both make cloud API calls to verify the status of resources, if you have your entire infrastructure in a single composition this can take many minutes.

Keep resource modules as plain as possible, do not nest the modules.

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"
      ...
    }
    

Carefully check the shared module before using it, do not push unsure code to production. πŸ™ˆπŸ™ˆπŸ™ˆ

In case the shared module includes unnecessary stuff, just copy the code partly. Thanks to open source. 😎😎😎

State store

Do not store the tfstate on Git as it exposes the infrastructure and contains sensitive data β€” other people on Git can view it.

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
      }
    }
    

    Enable version control on this bucket. And set IAM Policy to limit the access read/write to that bucket.

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 or terraform apply) or Terraform Cloud.

AWS User data (The user data to provide when launching the instance)