equinix-labs

Standards for experimental and labs projects by Equinix teams.

View the Project on GitHub equinix-labs/equinix-labs

Terraform Module Development Standards

This document serves as a guidepost for effective development with Terraform. Our goal is to provide a unified and consistent approach to managing code by outlining some rules of engagement. These following standards cover basic style and structure for your Terraform module configurations.

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119.

Terraform Modules

The following guidelines apply to both Child Modules and Root Modules.

Open source modules should be published to the Terraform Registry.

Module Structure

Lock File & Version Constraints

Terraform automatically creates a .terraform.lock.hcl file when it is initialized. The lock file helps guard against unintentional provider upgrades for configurations of production other long-lived infrastructure. A reusable module, such as the ones in Equinix Labs, should not maintain a lock file.

Instead, for any reusable terraform module:

Naming Convention

    # Good
    resource "equinix_metal_device" "web_server" { 
      name = web-server
      # ...
    }

    # Bad
    resource equinix_metal_device web-server {
      name = web-server
      # … 
    }

Variables

Outputs

Data Sources

Scripts (called by Terraform)

Helper scripts (not called by Terraform)

Static files

Templates

Resources

    resource "aws_db_instance" "main" { 
      name = "primary-instance"
      engine = "mysql"

      lifecycle { 
        prevent_destroy = true
      }
    }

Formatting

Expressions

    variable "readers" { 
      description = "..."
      type        = "list"
      default     = []
    } 
    resource "foo" "bar" {
      // Do not create this resource if the list of readers is empty.
      count = length(var.readers) == 0 ? 0 : 1 ...
    }

Child Modules

Modules that are meant for reuse should follow the following standards, as well as the normal Terraform guidelines.

Structure

Variables

    variable "labels" {
      description = "A map of labels to apply to contained resources."
      default     = {}
      type        = "map"
    }

Outputs

Inline Modules

Root Modules

Root configs, or root modules, are the working directories from which you run the Terraform CLI. They should follow the following standards, as well as the normal Terraform guidelines where applicable. Explicit recommendations for root modules supersede the general guidelines.

It is important to keep a single root config from ballooning in size with too many resources being stored in the same directory and state. This is because all resources in a particular root config are refreshed every time Terraform is run, which can lead to slow execution time if too many resources are included in a single state. A rule of thumb is that a single state shouldn’t include more than a ~100 resources, and ideally only a few dozen.

Resources for different applications and projects should be separated into their own Terraform directories that can be managed independently of each other. A service might represent a particular application or a common service like shared networking. Importantly, all the Terraform code for a particular service should be nested under one directory (including subdirectories).

Directory Structure

There are multiple ways to organize Terraform root configurations, especially when it comes to managing multiple environments. When it comes to managing the Terraform config for a particular service, the recommended structure is to use environment directories.

Directories per environment

In this style, each service must split its Terraform config into multiple directories. In this structure, the directory layout must be as follows:

-- SERVICE-DIRECTORY/
    -- OWNERS
    -- modules/
        -- service/
            -- main.tf
            -- variables.tf
            -- outputs.tf
            -- README
        -- ...other…
    -- environments/
        -- dev/
            -- backend.tf
            -- main.tf
            -- provider.tf
        -- qa/
            -- backend.tf
            -- main.tf
            -- provider.tf
        -- prod/
            -- backend.tf
            -- main.tf
            -- provider.tf

Environment directories

Each environment directory (dev, qa, prod) within corresponds to a Terraform Workspace and deploys a version of the service to that environment. This config should reference modules to share code across environments, including typically a service module which includes the base shared Terraform config for the service.

This environment directory must contain the following files:

Workspaces per environment

Alternatively, a single Terraform directory can be used per service and shared across environments. Each environment would have its own workspace.

When using workspaces, all environments share the same modules, and the configuration is driven by a tfvars file and a workspace. Workspaces are helpful in that they limit the amount of code that must be copy-pasted between environment directories, which can help enforce parity between environments while maintaining their own state files.

By default a single workspace named “default” exists. It is recommended to create and use a workspace for each environment and use the “default” workspace only when working with resources that may be used across multiple environments like some service accounts.

Workspaces can be listed with the workspace subcommand:

> terraform workspace list
* default

To create a new workspace:

> terraform workspace new prod
Created and switched to workspace "prod"!

You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.

Workspaces isolate their state by creating an additional state file in the state backend for each workspace.

You must select a workspace before issuing terraform commands to that workspace.

> terraform workspace select prod
Switched to workspace "prod".

Once you have switched to a workspace, all terraform commands work in the same manner you are accustomed to. A tfvars should be kept for each workspace to encapsulate the inputs for a given environment. tfvars can be stored in remote storage like GCS or S3 or encrypted and stored alongside code in a source code management system using KMS or a tool like git-crypt or git-secret.

> terraform apply -var-file=prod.tfvars

The tfvars file will drive the configuration of a specific environment. To enable/disable resources per environment, it is common to use the count attribute.

Example: prod.tfvars

replica_count = 5

Example: staging.tfvars

replica_count = 1

Example: dev.tfvars

replica_count = 0

Example: main.tf

resource "google_sql_database_instance" "primary" {
  name = "${terraform.workspace}-primary" //... 
}

resource "google_sql_database_instance" "replica" { 
  name = "${terraform.workspace}-replica-${count.index}"
  count = "${var.replica_count} //...
}

Outputs

Publishing outputs with remote states:

Versioning

Terraform

Terraform v0.13.0 is recommended that any module taking advantage of Provider Metadata functionality should specify a minimum Terraform version of 0.13.0 or higher.

terraform {
  required_version = "~> 0.13.0"
}

Equinix provider

In Terraform root configurations, you should pin the Equinix provider to a known good minor version. This will allow automatic upgrade to new patch releases while still keeping a solid target. Prerelease versions may only be an exact version constraint (the = operator or no operator). Prerelease versions do not match inexact operators such as >=, ~>, etc.

You should make updating the version pin a regular practice:

provider "equinix" {
  source = "equinix/equinix"
  version = "= 1.11.0"
}

In shared modules, the provider version should not be pinned. Instead, a version constraint should be added targeting the minimum working minor version. For example, using ~>1.11 indicates the module will work with any provider version >= 1.11, < 2.0.

terraform { 
  provider_meta "equinix" {
    # Set the name of the module below 
    module_name = "equinix-labs"
  }

  required_providers { 
    equinix = {
        source = "equinix/equinix"
        version = "~> 1.11"
    }
  }
}

Note, example configurations within modules (ex. in examples/ or in test configuration) are considered root configurations and can be pinned to a minor version.

Modules

References to shared modules must be constrained to a release tag. Targeting a specific commit hash or branch is dangerous as it gives no context to the version of the underlying module. Updating modules should involve as little guesswork as possible for both authors and reviewers.

Constrain by git reference

References to shared modules may be constrained to any arbitrary git reference (commit, branch, or tag). For reasons outlined above, we only recommend using this to reference tags:

module "vpc" { 
    source = "git::https://github.com/terraform-aws-modules/terraform-aws-vpc?ref=v3.18.0"
    ...
}

Constrain by version

When a git tag is released to the Terraform Module Registry, it creates a numbered version of that module (note that this does not apply to Github repositories, only to modules released to registries). An invocation can be constrained to said version:

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
  version = "3.18.1"
  ...
}

Portions of this page are modifications based on work created and shared by Google and used according to terms described in the Creative Commons 4.0 Attribution License.