An opinionated Terraform style guide
Infrastructure is getting more complex, the Terraform/OpenTofu adoption is growing, and somehow there’s fairly little guidance on managing these codebases at scale.
As a heavy user of Terragrunt, I rely a lot on small, focused Terraform modules that can be combined into larger infrastructure pieces externally (called units in the Terragrunt world). If you use pure Terraform/OpenTofu, you might also compose smaller modules into larger ones to achieve a similar effect.
Enforcing shared code style guidelines across all modules seems pretty obvious, especially with rising LLM-based contributions. Still, the official Terraform Style Guide is itself pretty light, and the OpenTofu Style Conventions even lighter. Both rely mostly on running terraform fmt and calling it a day.
Most of the actual style rules these days come from TFLint, the most prominent Terraform rule set being tflint-ruleset-terraform.
Even then, I believe we can go further and introduce more style rules. Terraform modules all follow the same basic structure: variables, resources, outputs. The blocks themselves also contain the same attributes: for_each, count, depends_on…
Let’s make sure we can always find the same information in the same place, in every module.
The rules
File naming
This is the most controversial and impactful one at the same time:
module/
00-variables.tf
01-providers.tf
02-locals.tf
03-data.tf
...
10-ec2.tf
20-alb.tf
30-hetzner.tf
...
99-outputs.tf
It might seem weird at first, but I find it hard to switch back after trying this structure. Everything is always in the same place and in the same order. File browsers display the correct order of files, and each file has a specific purpose:
00-variables.tfcontains allvariableblocks01-providers.tfcontains theterraformblock setting uprequired_providersandrequired_version02-locals.tfcontains all locals03-data.tfcontains alldatablocks99-outputs.tfcontains alloutputblocks- All other files contain
resourceblocks in the order you prefer, starting from index10
The only drawback is that people not familiar enough with Terraform could wrongly assume that the file naming has an impact on resource creation order.
Order of arguments
To make blocks uniform and easier to read:
for_eachandcountare always firstlifecycleanddepends_onare always last (in that order if both are used)- In
moduleblocks,sourceis always first afterfor_eachand/orcount variableblocks use the following argument order:type,nullable,sensitive,ephemeral,default,description,validationoutputblocks use the following argument order:description,sensitive,ephemeral,value,precondition,depends_on
Trailing commas
- Trailing commas must appear on all lines in lists, to make diffs smaller
- Trailing commas must never appear in maps as they are not necessary
my_list = [
1,
2,
]
my_dict = {
a = 1
b = 2
}
Map assignments
The map assignments must always be done with the equal sign (=). The colon (:) is not accepted, even though it is valid HCL.
foo = { a = 1 } # Perfect!
bar = { b : 1 } # Invalid, breaks some syntax highlighters
Comments
It’s something the official Terraform Style Guide mentions but is never enforced anywhere: only use #, for all comments.
Additionally, there should be a single space between the # and the comment content.
The spacing between the code and the comments is already handled by terraform fmt.
Multi-line lists
Surprisingly, terraform fmt accepts a lot of weird formatting for lists, these must not be used:
# Invalid
subnets = ["subnet-1", "subnet-2", ]
# Invalid
availability_zones = [
"us-east-1a",
"us-east-1b", ]
# Invalid
public_ips = ["1.1.1.1",
"2.2.2.2",
]
# Very invalid
dns_servers = ["8.8.8.8"
, "8.8.4.4"]
Empty lines
- There can never be two empty lines in a row, anywhere. You can use comments to separate sections of the code if needed instead.
for_each,count,depends_onandlifecyclemust be separated from the rest of the arguments by an empty line.- There should be a single empty line at the end of every file. Most editors can be configured to do this automatically.
- Empty files are not allowed.
Composability
If you want to keep your modules lean and composable, you likely want to enforce these:
- No
providerorbackenddeclarations are allowed within modules. This is the responsibility of Terragrunt units or higher-level Terraform modules. - The
providerargument should be forbidden in most cases. Dealing with multiple providers of the same kind in a single module is rarely a good idea.
Enforcing the rules
All rules are packaged into a TFLint plugin called tflint-ruleset-terraform-style (GitHub link), or simply terraform_style. You can add it to your .tflint.hcl this way:
plugin "terraform_style" {
enabled = true
version = "0.2.1"
source = "github.com/Heldroe/tflint-ruleset-terraform-style"
}
This should be used in conjunction with other tools like terraform fmt, terraform validate, and TFLint rule sets like tflint-ruleset-terraform.
Customization
Most rules are customizable: you can change the expected file names, starting index for resource files, order of arguments, allowed blocks in each file… For example:
rule "terraform_style_terraform_file" {
enabled = true
filename = "07-setup"
allowed_blocks = ["terraform", "locals"]
}
rule "terraform_style_output_arguments" {
enabled = true
order = ["value", "description"]
}
And of course, each rule can be enabled or disabled individually.