Terraform Best Practices
These are the opinionated best-practices we follow at Cloud Posse. They are inspired by years of experience writing terraform and borrow on the many other helpful resources like those by HashiCorp.
See our general Best Practices which also apply to Terraform.
Variables
Use upstream module or provider variable names where applicable
When writing a module that accepts variable
inputs, make sure to use the same names as the upstream to avoid confusion and ambiguity.
Use all lower-case with underscores as separators
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.
Use positive variable names to avoid double negatives
All variable
inputs that enable/disable a setting should be formatted ...._enabled
(e.g. encryption_enabled
). It is acceptable for
default values to be either false
or true
.
Use description field for all inputs
All variable
inputs need a description
field. When the field is provided by an upstream provider (e.g. terraform-aws-provider
), use same wording as the upstream docs.
Use sane defaults where applicable
Modules should be as turnkey as possible. The default
value should ensure the most secure configuration (E.g. with encryption enabled).
Use nullable = false
where appropriate
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.
Use feature flags, list, or map inputs for optional functionality
All Cloud Posse modules should respect the null-label
enabled
feature flag, and when enabled
is false
, create no resources
and generate null outputs (or, in the case of output lists, maps, and objects,
empty values may be acceptable to avoid having other modules consuming the
outputs fail due to having a null rather than empty value).
Optional functionality should be toggled in either of 2 ways:
- Use of a feature flag. Specifically, an input variable of type
bool
with a name ending in_enabled
. Use this mechanism if the option requires no further configuration, e.g.iam_role_enabled
ors3_bucket_enabled
. Feature flags should always benullable = false
, but the default value can betrue
orfalse
as desired. - If an optional feature requires further configuration, use a
list
ormap
input variable, with an empty input disabling the option and non-empty input providing configuration. In this case, only use a separate feature flag if the list or map input may still cause problems due to relying on computed problems during the plan phase. See Count vs. For Each and Terraform Errors When Planning for more information. - It is never acceptable for an optional feature of the module to be toggled by the value of a string or number input variable, due to the issues explained in Terraform Errors When Planning. However, if an optional feature of a resource may be toggled by such an input if that is the behavior of the resource and the input has the same name as the resource argument.
Use objects with optional fields for complex inputs
When a module requires a complex input, use an object with optional fields.
This provides documentation and plan-time validation while avoiding type
conversion errors, and allows for future expansion without breaking changes.
Make as many fields as possible optional, provide defaults at every level of
nesting, and use nullable = false
if possible.
If you use an object with defaults as an input, Terraform will not give any
indication if the user provides extra fields in the input object. This is
particularly a problem if they misspelled an optional field name, because
the misspelled field will be silently ignored, and the default value the
user intended to override will silently be used. This is
a limitation of Terraform.
Furthermore, there is no way to add any checks for this situation, because
the input will have already been transformed (unexpected fields removed) by
the time any validation code runs. This makes using an object a trade-off
versus using separate inputs, which do not have this problem, or type = any
which allows you to write validation code to catch this problem and
additional code to supply defaults for missing fields.
Reserve type = any
for exceptional cases where the input is highly
variable and/or complex, and the module is designed to handle it. For
example, the configuration of a Datadog synthetic test
is both highly complex and the Cloud Posse module
accepts both an object derived from the synthetics_test
resource schema or
an object derived from the JSON output of the Datadog API. In this rare case,
attempting to maintain a type definition would not only be overly complex,
it would slow the adoption of future additions to the interface, and so
type = any
is appropriate.
Prefer a single object over multiple simple inputs for related configuration
When reviewing Cloud Posse modules as examples, you may notice that they often use a large number of input variables of simple types. This is because in the early development of Terraform, there was no good way to define complex objects with defaults. 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, prefer:
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"
}
However, using an object with defaults versus multiple simple inputs is not without trade-offs, as explained in the above caution.
There are a few ways to mitigate this problem besides using separate inputs:
- If all the defaults are null or empty, you can use a
map(string)
input variable and use thekeys
function to check for unexpected fields. This catches errors, but has the drawback that it does not provide documentation of what fields are expected. - You can use
type = any
for inputs, but then you have to write the extra code to validate the input and supply defaults for missing fields. You should also document the expected fields in the input description. - If all you are worried about is misspelled field names, you can make the
correctly spelled field names required, ensuring they are supplied.
Alternatively, if the misspelling is predictable, such as you have a field
named
minsize
but people are likely to try to supplymin_size
, you can make the misspelled field name optional with a sentinel value and then check for that value in the validation code.
Use custom validators to enforce custom constraints
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.
For example, if the module takes an optional IPv6 CIDR block, you might
receive that in a variable of list(string)
for reasons explained
here.
Use a custom validator to ensure that the list has at most one element, because
if it has more than one, the module will ignore the extra elements, while a
reasonable person might expect them to be used in some way. At the same time, is it not necessary to use a custom validator to enforce
that the input is a valid IPv6 CIDR block, because Terraform will already
do that for you.
This is perhaps better illustrated by an example of a pseudo-enumeration.
Terraform does not support real enumerations, so they are typically
implemented as strings with a limited number of acceptable values. For example,
Say you have a resource that takes a frequency
input that is a string that
must be either DAILY
or WEEKLY
. Even though the value of the string is very
restricted, you should not use a custom validator to enforce that for two reasons.
- Terraform (technically, the Terraform resource provider) will already enforce that the string is one of those two values and produce an informative error message if it is not.
- If you use a custom validator to enforce that the string is one of those two values, and then a later version of the resource adds a new option to the enumeration, such as "HOURLY", the custom validator will prevent the module from using the new value, even though the module would function perfectly were it not for the validator. This adds work and delay to the adoption of underlying enhancements to the resource, without providing enough benefit to be worth the extra effort.
Use variables for all secrets with no default
value and mark them "sensitive"
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.
Outputs
Use description field for all outputs
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
.
Use well-formatted snake case output names
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.
Never output secrets
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. Values written to SSM are easily retrieved by other terraform modules, or even on the command-line using tools like chamber by Segment.io.
We are very strict about this in our components (a.k.a root modules), the
top-most module, because these sensitive outputs are easily leaked in CI/CD
pipelines (see tfmask
for masking
secrets in output only as a last resort). We are less sensitive to this in
modules that are typically nested inside of other modules.
Rather than outputting a secret, you may output plain text indicating where
the secret is stored, for example RDS master password is in SSM parameter /rds/master_password
. You may also want to have another output just for the
key for the secret in the secret store, so the key is available to other
programs which may be able to retrieve the value given the key.
Regardless of whether the secret is output or not, the fact that a secret is known to Terraform means that its value is stored in plaintext in the Terraform state file. Storing values in SSM Parameter Store or other places does not solve this problem. Even if you store the value in a secure place using some method other than Terraform, if you read it into Terraform, it will be stored in plaintext in the state file.
To keep the secret out of the state file, you must both store and retrieve the secret outside of Terraform. This is a limitation of Terraform that has been discussed practically since Terraform's inception, so a near-term solution is unlikely.
Use symmetrical names
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.