Terrafrom Coding
General Guidelines
- Always break down tasks into smaller simpler components, which only have access to the information that they need. Doing too much in one chunk isn’t manageable or maintainable when creating, understanding, testing and bug fixing code. Always functionize code and inject dependencies/parameters. This also makes it clearer as to what objects need to function and, thus, makes the code easier to test.
- Make sure your implementation has been minimally tested in order to verify that everything still works as it should. Automated tests can also help to identify issues early in the development process before they become a problem
- Write code to make it easy to read and understand and use descriptive names so your colleagues can decipher everything. If a piece of code is to complex to understand using only descriptive naming, use comments to explain what it is doing and why. All of our code is written with the awareness that it will be peer-reviewed.
- If you are stuck working on your task for 30 minutes, raise it to the team. Ask for help if anyone is available to have a pair or mob programming session with you, depending on the complexity of the task
- Look for existing frameworks, review and contribute to them. If a public package/module already implement the pattern you want, why re-invent the wheel?
- Recognize when it's time to refactor. If a piece of code is straying from its original intent, and there's a better way of doing it you should flag it for refactoring so the team can analyze and discuss the problem.
- Commit to the source code repository often, at a minimum once a day. Regularly pushing up your code will help ensure you don’t lose any work if something goes wrong with your computer or software, and it also makes it easier to track changes and keep track of who made which changes and when.
- Contributing guidelines must be documented in the CONTRIBUTING.md file. This file must also include the tools setup required to make changes to the source code.
- Specifies files that Git should ignore in a .gitignore file at the root of the repository.
- Development happens on feature and bug-fix branches that branch off of the main branch. By standard the company here (opens in a new tab)
- Name feature branches feature/feature-name.
- Name bug-fix branches fix/bugfix-name.
- To prevent merge conflicts, always pull the latest version of the main branch into your feature/fix branch.
Code Structure
NOTE
The Code Structure will be updated regulary to match the technology and users needs and level of the developer .
- Structuring of Terrafrom configurations
-
Root module/directory: This should be the primary entrypoint for the module and is expected to be opinionated. More complex architectures will use specific nested modules to create lightweight abstractions, so that you can describe infrastructure in terms of its architecture, rather than directly in terms of physical objects.
-
README: The root module and any nested modules should have README files. This file must be named README.md. It should contain a description of the module and what it should be used for. If you want to include an example for how this module can be used in combination with other resources, put it in an
examples
directory. Consider including a visual diagram depicting the infrastructure resources the module may create and their relationship. -
variables.tf and outputs.tf: Contain the declarations for variables and outputs, respectively. All variables and outputs should have one or two sentence descriptions that explain their purpose. This is used for documentation. See the documentation for variable configuration (opens in a new tab) and output configuration (opens in a new tab) for more details.
-
All variables must have a defined type.
-
The variable declaration can also include a default argument. If present, the variable is considered to be optional and the default value will be used if no value is set when calling the module or running Terraform. The default argument requires a literal value and cannot reference other objects in the configuration. To make a variable required for user to set, omit a default in the variable declaration and consider if setting
nullable = false
makes sense. -
For variables that have environment-independent values (such as disk size), provide default values.
-
For variables that have environment-specific values (such as
project_id
), don't provide default values. This way, the calling module must provide meaningful values. -
Use empty defaults for variables (like empty strings or lists) only when leaving the variable empty is a valid preference that the underlying APIs don't reject.
-
Be judicious in your use of variables. Only parameterize values that must vary for each instance or environment. When deciding whether to expose a variable, ensure that you have a concrete use case for changing that variable. If there's only a small chance that a variable might be needed, don't expose it.
- Adding a variable with a default value is backwards-compatible.
- Removing a variable is backwards-incompatible.
- In cases where a literal is reused in multiple places, you can use a local value without exposing it as a variable.
-
Don't pass outputs directly through input variables, because doing so prevents them from being properly added to the dependency graph. To ensure that implicit dependencies (opens in a new tab) are created, make sure that outputs reference attributes from resources. Instead of referencing an input variable for an instance directly, pass the attribute.
-
-
locals.tf: Contains local values that assign a name to an expression, so a name can be used multiple times within a module instead of repeating the expression. Local values are like a function's temporary local variables. The expressions in local values are not limited to literal constants; they can also reference other values in the module in order to transform or combine them, including variables, resource attributes, or other local values.
-
providers.tf: Contains the terraform block (opens in a new tab) and provider blocks (opens in a new tab).
provider
blocks must only be declared in root modules by consumers of modules.If using Terraform Cloud/Enterprise, also add an empty cloud block (opens in a new tab). The
cloud
block is configured entirely through environment variables (opens in a new tab) and environment variables credentials (opens in a new tab) as part of a CICD Pipeline. -
versions.tf: Contains the required_providers (opens in a new tab) block. Each Terraform module must declare which providers it requires, so that Terraform can install and use them.
-
data.tf: For simple configuration, put data sources (opens in a new tab) next to the resources that reference them. For example, if you are fetching an image to be used in launching an instance, place it alongside the instance instead of collecting data resources in their own file. If the number of data sources becomes large, consider moving them to a dedicated
data.tf
file. -
.tfvars files: For root modules, provide variables by using a
.tfvars
variables file. For consistency, name variable filesterraform.tfvars
. Place common values at the root of the repository and environment specific values within theenvs/
folder. -
Nested modules: Nested modules must exist under the
modules/
subdirectory. Any nested module with aREADME.md
is considered usable by an external user. If a README doesn't exist, it is considered for internal use only. Nested modules should be used to split complex behavior into multiple small modules that users can carefully pick and choose.If the root module includes calls to nested modules, they should use relative paths like ./modules/sample-module so that Terraform will consider them to be part of the same repository or package, rather than downloading them again separately.
If a repository or package contains multiple nested modules, they should ideally be composable by the caller, rather than calling directly to each other and creating a deeply-nested tree of modules.
-
Service named files: Often users want to create several files and separate terraform resources by service. This urge should be stifled as much as possible in favor of defining resources in
main.tf
. If a collection of resources, for example IAM Roles and Policies, exceed 150 lines then it is reasonable to break that into its own files such asiam.tf
. Otherwise all resource code should be defined in themain.tf
. -
Custom Scripts: Use scripts only when necessary. The state of resources created through scripts is not accounted for or managed by Terraform. Use them only when Terraform resources don't support the desired behavior. Put custom scripts called by Terraform in a
scripts/
directory. -
Helper Scripts: Organize helper scripts that aren't called by Terraform in a
helpers/
directory. Document helper scripts in theREADME.md
file with explanations and example invocations. If helper scripts accept arguments, provide argument-checking and--help
output. -
Static Files: Static files that Terraform references but doesn't execute (such as startup scripts loaded onto Amazon EC2 instances) must be organized into a
files/
directory. Place lengthy HereDocs in external files, separate from their HCL. Reference them with the file() function (opens in a new tab). -
Templates: For files that are read in by using the Terraform templatefile function (opens in a new tab), use the file extension
.tftpl
. Templates must be placed in atemplates/
directory.
- Structure folder
-
Single Workspace per Repository Branch
- This is a mainstream practice where you separate or isolate the branches into three or (two) environments - typically dev,staging and production. This concept called a long-running branches. Each Terraform Workspaces is listened for a changes to a specific environment branches.
- Each Terraform Workspaces is listened for a changes to a specific environment branches.
- For an infrastructure that has the same configuration across the environments.
- Fewer files to maintain, potential code conflict are lesser, with fewer Terraform Plans to run.
- You only need to create a PR and merge to the selected branches and let Terraform Workspace do the rest works on applying the code changes on to your infrastructure environment.
- Team changes can cross-contimate environments.
- Branches can drift out of sync.
enviroment/
├── develop/
│ ├── main.tf
| ├── terraform.tfvars
| ├── outputs.tf
| ├── outputs.tf
| ├── variables.tf
├── production/
modules/
-
Single Workspace per Repository Directory
- Usually you will use a single repository and separate the environment by directories - typically dev,staging and production.
- The long-running branch concept are applied to 'main' branch where we constantly merge any feature/hotfix branch into the main branch.
- Each Terraform Workspaces is aligned to a different environment directory. It will listens to changes on main branch in specified directory.
- For a team/organization that practice a short-lived branches concept where the branches are frequently merged into main branch.
- For an infrastructure that has significant differences on configuration across the three environments.
- All infrastructure states in a unique environment directory.
- You may need to put extra review times to govern the pull request review on every changes.
- Have to do manual promotion for each code changes across environments.
terraform/
├─ application-resources
├─ functionality
├─ initializations
├─ modules
├─ main.tf
├─ dev.tfvars
├─ prod.tfvars
├─ state.tf
└─ variables.tf
Naming conventions
- Resource meta names must be snake-cased and should be contextual to the resource being created. This practice ensures consistency with the naming convention for resource types, data source types, and other predefined values. This convention does not apply to name arguments (opens in a new tab).
- To simplify references to a resource that is the only one of its type (for example, a single load balancer for an entire module), name the resource
main
. - Make resource names singular.
- In the resource name, don't repeat the resource type.
- Inputs, local variables, and outputs representing numeric values (e.g., disk size, RAM size) must be named with units (e.g.,
ram_size_gb
). Naming variables with units makes the expected input unit clear for configuration maintainers. - Give boolean variables positive names. For example,
enable_external_access
.
Variables
- Order keys in a variable block like this: description , type, default
- Always include description on all variables even if you think it is obvious (you will need it in the future).
- Prefer using simple types (number, string, list(...), map(...), any) over specific type like object() unless you need to have strict constraints on each key
- Use type any to disable type validation starting from a certain depth or when multiple types should be supported.
Outputs
-
Good structure for the name of output looks like
{name}_{type}_{attribute}
{name}
is a resource or data source name without a provider prefix.{name}
foraws_subnet
issubnet
,foraws_vpc
it isvpc
.{type}
is a type of a resource sources{attribute}
is an attribute returned by the output
-
If the output is returning a value with interpolation functions and multiple resources,
{name}
and{type}
-
Always include description for all outputs even if you think it is obvious.
// ✅ Do
output "security_group_id" {
description = "The ID of the security group"
value = aws_security_group.web.id
}
// 🚩 Don't
output "this_security_group_id" {
value = aws_security_group.web.id
}
Stateful Resources
For stateful resources, such as databases, ensure that deletion protection (opens in a new tab) is enabled.
Built-In Formatting and Validation
All Terraform files must conform to the standards of terraform fmt (opens in a new tab).
Use terraform validate (opens in a new tab) to verify the syntax and structure of your configuration.
Expressions Complexity
Limit the complexity of any individual interpolated expressions. If many functions are needed in a single expression, consider splitting it out into multiple expressions by using local values (opens in a new tab).
Never have more than one ternary (opens in a new tab) operation in a single line. Instead, use multiple local values to build up the logic.
Conditional Values
To instantiate a resource conditionally, use the count (opens in a new tab) meta-argument. For example: count = length(var.some_value) == 0 ? 0 : 1
Iterated Resources
Terraform can dynamically create resources using either count (opens in a new tab) or for_each (opens in a new tab). for_each
should always be preferred over count
except for circumstances where only count = 0 or 1 like explained in the Conditional Values section. The reasoning for this comes from the behavior fundamental to lists vs maps; Lists are ordered; say you create 3 subnets [subnet0
, subnet1
, subnet2
]. If you have to erase subnet 0 or 1, Terraform’s state file will see a change to the list and cause cascading unexpected changes. Using for_each
resources are named using the map key.
For example with aws_subnet.test[0].id
vs aws_subnet.test["private_subnet0"].id
, you can delete private_subnet0
without any fear of unintended consequences.
Attachment Resources
Some resources have pseudo resources embedded as attributes in them. Where possible, you should avoid using these embedded resource attributes and instead you should use the unique resource to attach that pseudo-resource. These resource relationships can cause chicken/egg issues that are unique per resource.
Using embedded attribute (avoid this pattern):
resource "aws_security_group" "allow_tls" {
...
ingress {
description = "TLS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [aws_vpc.main.cidr_block]
ipv6_cidr_blocks = [aws_vpc.main.ipv6_cidr_block]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
}
Using attachment resources (preferred):
resource "aws_security_group" "allow_tls" {
...
}
resource "aws_security_group_rule" "example" {
type = "ingress"
description = "TLS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [aws_vpc.main.cidr_block]
ipv6_cidr_blocks = [aws_vpc.main.ipv6_cidr_block]
security_group_id = aws_security_group.allow_tls.id
}
Workspaces
Workspace-separated environments use the same Terraform code but have different state files, which keep the environments as similar to each other as possible. Using workspaces organizes the resources in your state file by environments, so you only need one output value definition.
Define your environment specific variables for each environment using .tfvars files
(non-sensitive) and workspace variables (opens in a new tab) (sensitive).
Management Terraform State
- Use backend defines to store state data files
S3 Store state
- Stores the state as a given key in a given bucket on Amazon S3.
// ✅ Example
terraform {
backend "s3" {
bucket = "mybucket"
key = "path/to/my/key"
region = "us-east-1"
}
}
Locking and Teamwork
terraform {
backend "s3" {
bucket = "myorg-terraform-states"
key = "myapp/production/tfstate"
region = "us-east-1"
dynamodb_table = "TableName"
encrypt = true
}
}