Standards for experimental and labs projects by Equinix teams.
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.
The following guidelines apply to both Child Modules and Root Modules.
Open source modules should be published to the Terraform Registry.
main.tf
file, where resources are located by default.
README.md
(with basic documentation in Markdown format).examples/
, each with its own subdirectory and a README.md
file.network.tf
, instances.tf
, or loadbalancer.tf
.
project.tf
.*.tf
) and repo metadata files (like README.md
, CHANGELOG.md
, or kitchen.yml
) should exist at the root directory of a module.docs/
subdirectory.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:
.terraform.lock.hcl
is included in .gitignore
and remove it from git if it was committed previously # Good
resource "equinix_metal_device" "web_server" {
name = “web-server”
# ...
}
# Bad
resource “equinix_metal_device” “web-server” {
name = “web-server”
# …
}
variables.tf
.project_id
) should not be given defaults. This forces the calling module to provide meaningful values.outputs.tf
.README
. Descriptions should also be auto-generated on commit with terraform-docs. output "name" {
description = "Name of instance"
# NOT THIS:
# value = var.name
value = equinix_metal_device.main.name
}
data.tf
file.scripts/
.ignore_changes = [*]
on such resources.helpers/
directory.README
with an explanation and example invocations.--help
output.files/
.file()
function..tftpl
.templates/
.some_metal_resource.my_unique_name.id
vs. some_metal_resource.main.id
.db_instance
).resource "equinix_metal_ip_attachment" "main" { ... }
Not this: resource "equinix_metal_ip_attachment" "main_ip_attachment" { ... }
resource "aws_db_instance" "main" {
name = "primary-instance"
engine = "mysql"
lifecycle {
prevent_destroy = true
}
}
terraform fmt
.count
variable for resources.
project_id
) and that resource does not yet exist, Terraform will not be able to generate a plan and will report the error “value of count cannot be computed.” 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 ...
}
Modules that are meant for reuse should follow the following standards, as well as the normal Terraform guidelines.
OWNERS
file (or CODEOWNERS
on GitHub) documenting who is responsible for the module.labels
variable with a default value of an empty map to apply throughout labelable resources: variable "labels" {
description = "A map of labels to apply to contained resources."
default = {}
type = "map"
}
modules/$modulename
.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).
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.
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
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:
backend.tf
file declaring the Terraform backend state location.main.tf
file which instantiates the service module.provider.tf
file which declares provider configuration.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} //...
}
Publishing outputs with remote states:
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"
}
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.
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.
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"
...
}
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.