Terraform is a powerful tool for managing infrastructure as code. However, like any tool, Terraform has its own set of best practices that you should follow to ensure that your infrastructure is secure, reliable, and maintainable. This guide provides opinionated recommendations for Terraform best practices based on CloudPosse and HashiCorp guidance.
Avoid introducing any other syntaxes commonly found in other languages such as CamelCase or pascalCase. For consistency we want all variables to look uniform. This is also inline with the HashiCorp naming conventions.
For example:
variable "instance_type" {
type = string
description = "The type of instance to launch"
default = "t2.micro"
}
All variable
inputs that enable or disable a setting should be formatted <variable_name>_enabled
. It is acceptable for default values to be either false
or true
.
For example:
variable "public_subnet_enabled" {
type = bool
description = "Enables the network subnet to be accessible to the internet"
default = true
}
All variable inputs need a description
field. When the field is provided by an upstream provider, use the same wording as the upstream documentation.
For example:
variable "encryption_enabled" {
type = bool
description = "Enable encryption for the bucket"
default = true
}
When passing an argument to a resource, passing null
means to use the default value. Prior to Terraform version 1.1.0
, passing null
to a module input set that value to null
rather than to the default value.
Starting with Terraform version 1.1.0
, variables can be declared as nullable = false
which:
- Prevents the variable from being set to
null
- Causes the variable to be set to the default value if
null
is passed in
You should always use nullable = false
for all variables which should never be set to null
. This is particularly important for lists, maps, and objects, which, if not required, should default to empty values (i. e. {} or []) rather than null
. It can also be useful to set strings to default to "" rather than null and set nullable = false
. This will simplify the code since it can count on the variable having a non-null value.
The default nullable = true
never needs to be explicitly set. Leave variables with the default nullable = true
if a null value is acceptable.
However, now that Terraform supports complex objects with field-level defaults, we recommend using a single object input variable with such defaults to group related configuration, taking into consideration the trade-offs listed in the above caution. This makes the interface easier to understand and use.
For example:
variable "eip_timeouts" {
type = object({
create = optional(string)
update = optional(string)
delete = optional(string, "30m")
}))
default = {}
nullable = false
}
rather than:
variable "eip_create_timeout" {
type = string
default = null
}
variable "eip_update_timeout" {
type = string
default = null
}
variable "eip_delete_timeout" {
type = string
default = "30m"
}
Use the validation block to enforce custom constraints on input variables. A custom constraint is one that, if violated, would not otherwise cause an error, but would cause the module to behave in an unexpected way.
All variable
inputs for secrets must never define a default
value. This ensures that Terraform is able to validate user input. The exception to this is if the secret is optional and will be generated for the user automatically when left null
or ""
(empty).
Use sensitive = true
to mark all secret variables as sensitive. This ensures that the value is not printed to the console.
All outputs must have a description
set. The description
should be based on (or adapted from) the upstream terraform provider where applicable. Avoid simply repeating the variable name as the output description
.
Avoid introducing any other syntaxes commonly found in other languages such as CamelCase or pascalCase. For consistency, we want all variables to look uniform. It also makes code more consistent when using outputs together with terraform remote_state
to access those settings from across modules.
Secrets should never be outputs of modules. Rather, they should be written to secure storage such as AWS Secrets Manager, AWS SSM Parameter Store with KMS encryption, or S3 with KMS encryption at rest. Our preferred mechanism on AWS is using SSM Parameter Store.
We prefer to keep terraform outputs symmetrical as much as possible with the upstream resource or module, with exception of prefixes. This reduces the amount of entropy in the code or possible ambiguity, while increasing consistency. Below is an example of what *not to do. The expected output name is user_secret_access_key
. This is because the other IAM user outputs in the upstream module are prefixed with user_
, and then we should borrow the upstream's output name of secret_access_key
to become user_secret_access_key
for consistency.
Using <<-EOT
(as opposed to <<EOT
without the -)
ensures the code can be indented inline with the other code in the project. Note that EOT
can be any uppercase string (e.g. CONFIG_FILE
)
For example:
block {
value = <<-EOT
hello
world
EOT
}
There are better ways to achieve the same outcome using terraform interpolations or resources:
- For JSON, use a combination of a local and the
jsonencode
function - For YAML, use a combination of a local and the
yamlencode
function - For IAM Policy Documents, use the native
iam_policy_document
resource
Use instead the templatefile
function and move the configuration to a separate template file.
Linting helps to ensure a consistent code formatting, improves code quality and catches common errors with syntax. Run terraform fmt before committing all code.
Using locals
makes code more descriptive and maintainable. Rather than using complex expressions as parameters to some terraform resource, instead move that expression to a local
and reference the local
in the resource.