Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Questions related to Terraform code structure are by far the most frequent in the community. Everyone thought about the best code structure for the project at some point also.
This is one of the questions where lots of solutions exist and it is very hard to give universal advice, so let's start with understanding what are we dealing with.
Few resources, no external dependencies. Single AWS account. Single region. Single environment.
Yes
Several AWS accounts and environments, off-the-shelf infrastructure modules using Terraform.
Yes
Many AWS accounts, many regions, urgent need to reduce copy-paste, custom infrastructure modules, heavy usage of compositions. Using Terraform.
WIP
very-large
Several providers (AWS, GCP, Azure). Multi-cloud deployments. Using Terraform.
No
medium
Several AWS accounts and environments, off-the-shelf infrastructure modules, composition pattern using Terragrunt.
No
large
Many AWS accounts, many regions, urgent need to reduce copy-paste, custom infrastructure modules, heavy usage of compositions. Using Terragrunt.
No
very-large
Several providers (AWS, GCP, Azure). Multi-cloud deployments. Using Terragrunt.
No
Number of related resources
Number of Terraform providers (see note below about "logical providers")
How often does your infrastructure change?
From once a month/week/day
To continuously (every time when there is a new commit)
Code change initiators? Do you let the CI server update the repository when a new artifact is built?
Only developers can push to the infrastructure repository
Everyone can propose a change to anything by opening a PR (including automated tasks running on the CI server)
Which deployment platform or deployment service do you use?
AWS CodeDeploy, Kubernetes, or OpenShift require a slightly different approach
How environments are grouped?
By environment, region, project
Putting all code in main.tf is a good idea when you are getting started or writing an example code. In all other cases you will be better having several files split logically like this:
main.tf - call modules, locals, and data sources to create all resources
variables.tf - contains declarations of variables used in main.tf
outputs.tf - contains outputs from the resources created in main.tf
versions.tf - contains version requirements for Terraform and providers
terraform.tfvars should not be used anywhere except composition.
It is easier and faster to work with a smaller number of resources
terraform plan and terraform apply both make cloud API calls to verify the status of resources
If you have your entire infrastructure in a single composition this can take some time
A blast radius (in case of security breach) is smaller with fewer resources
Insulating unrelated resources from each other by placing them in separate compositions reduces the risk if something goes wrong
Start your project using remote state because:
Your laptop is no place for your infrastructure source of truth
Managing a tfstate file in git is a nightmare
Practice a consistent structure and convention:
Like procedural code, Terraform code should be written for people to read first, consistency will help when changes happen six months from now
It is possible to move resources in Terraform state file but it may be harder to do if you have inconsistent structure and naming
Keep resource modules as plain as possible
Don't hardcode values that can be passed as variables or discovered using data sources
Use data sources and terraform_remote_state specifically as a glue between infrastructure modules within the composition
In this book, example projects are grouped by complexity - from small to very-large infrastructures. This separation is not strict, so check other structures also.
Having a small infrastructure means that there is a small number of dependencies and few resources. As the project grows the need to chain the execution of Terraform configurations, connecting different infrastructure modules, and passing values within a composition becomes obvious.
There are at least 5 distinct groups of orchestration solutions that developers use:
Terraform only. Very straightforward, developers have to know only Terraform to get the job done.
Terragrunt. Pure orchestration tool which can be used to orchestrate the entire infrastructure as well as handle dependencies. Terragrunt operates with infrastructure modules and compositions natively, so it reduces duplication of code.
In-house scripts. Often this happens as a starting point towards orchestration and before discovering Terragrunt.
Ansible or similar general purpose automation tool. Usually used when Terraform is adopted after Ansible, or when Ansible UI is actively used.
and other Kubernetes-inspired solutions. Sometimes it makes sense to utilize the Kubernetes ecosystem and employ a reconciliation loop feature to achieve the desired state of your Terraform configurations. View video for more information.
With that in mind, this book reviews the first two of these project structures, Terraform only and Terragrunt.
See examples of code structures for Terraform or Terragrunt in the next chapter.
The official Terraform documentation describes all aspects of configuration in details. Read it carefully to understand the rest of this section.
This section describes key concepts which are used inside the book.
Resource is aws_vpc, aws_db_instance, etc. A resource belongs to a provider, accepts arguments, outputs attributes, and has a lifecycle. A resource can be created, retrieved, updated, and deleted.
Resource module is a collection of connected resources which together perform the common action (for e.g., creates VPC, subnets, NAT gateway, etc). It depends on provider configuration, which can be defined in it, or in higher-level structures (e.g., in infrastructure module).
An infrastructure module is a collection of resource modules, which can be logically not connected, but in the current situation/project/setup serves the same purpose. It defines the configuration for providers, which is passed to the downstream resource modules and to resources. It is normally limited to work in one entity per logical separator (e.g., AWS Region, Google Project).
For example, module uses resource modules like and to manage the infrastructure required for running on .
Another example is module where multiple modules by are being used together to manage the infrastructure as well as using Docker resources to build, push, and deploy Docker images. All in one set.
Composition is a collection of infrastructure modules, which can span across several logically separated areas (e.g.., AWS Regions, several AWS accounts). Composition is used to describe the complete infrastructure required for the whole organization or project.
A composition consists of infrastructure modules, which consist of resources modules, which implement individual resources.
Data source performs a read-only operation and is dependant on provider configuration, it is used in a resource module and an infrastructure module.
Data source terraform_remote_state acts as a glue for higher-level modules and compositions.
The data source allows an external program to act as a data source, exposing arbitrary data for use elsewhere in the Terraform configuration. Here is an example from the where the filename is computed by calling an external Python script.
The data source makes an HTTP GET request to the given URL and exports information about the response which is often useful to get information from endpoints where a native Terraform provider does not exist.
Store for each infrastructure module and composition in a remote backend, configured with ACLs, versioning, and logging. This single, authoritative source of truth keeps environments consistent and typically includes disaster-recovery features such as automated backups. Managing state locally can lead to collaboration issues and race conditions when multiple developers run Terraform at the same time, resulting in unpredictable outcomes.
Providers, provisioners, and a few other terms are described very well in the official documentation and there is no point to repeat it here. To my opinion, they have little to do with writing good Terraform modules.
While individual resources are like atoms in the infrastructure, resource modules are molecules (consisting of atoms). A module is the smallest versioned and shareable unit. It has an exact list of arguments, implement basic logic for such a unit to do the required function. e.g., module creates aws_security_group and aws_security_group_rule resources based on input. This resource module by itself can be used together with other modules to create the infrastructure module.
Access to data across molecules (resource modules and infrastructure modules) is performed using the modules' outputs and data sources.
Access between compositions is often performed using remote state data sources. There are .
When putting concepts described above in pseudo-relations it may look like this:
This document is an attempt to systematically describe best practices using Terraform and provide recommendations for the most frequent problems Terraform users experience.
is powerful (if not the most powerful out there now) and one of the most used tools which allow management of infrastructure as code. It allows developers to do a lot of things and does not restrict them from doing things in ways that will be hard to support or integrate with.
Some information described in this book may not seem like the best practices. I know this, and to help readers to separate what are established best practices and what is just another opinionated way of doing things, I sometimes use hints to provide some context and icons to specify the level of maturity on each subsection related to best practices.
The book was started in sunny Madrid in 2018, available for free here at .
A few years later it has been updated with more actual best practices available with Terraform 1.0. Eventually, this book should contain most of the indisputable best practices and recommendations for Terraform users.
Please contact me if you want to become a sponsor.
— Terraform Compliance Simplified. Make your Terraform modules compliance-ready.
Contact me if you want to help translate this book into other languages.
I always want to get feedback and update this book as the community matures and new ideas are implemented and verified over time.
If you are interested in specific topics, please open an issue, or thumb up an issue you want to be covered. If you feel that you have content and you want to contribute, write a draft and submit a pull request (don't worry about writing good text at this point!).
This book is maintained by Anton Babenko with the help of different contributors and translators.
This work is licensed under Apache 2 License. See LICENSE for full details.
The authors and contributors to this content cannot guarantee the validity of the information found here. Please make sure that you understand that the information provided here is being provided freely, and that no kind of agreement or contract is created between you and any persons associated with this content or project. The authors and contributors do not assume and hereby disclaim any liability to any party for any loss, damage, or disruption caused by errors or omissions in the information contained in, associated with, or linked from this content, whether such errors or omissions result from negligence, accident, or any other cause.
Copyright © 2018-2023 Anton Babenko.
Source: https://github.com/antonbabenko/terraform-best-practices/tree/master/examples/small-terraform
This example contains code as an example of structuring Terraform configurations for a small-size infrastructure, where no external dependencies are used.
Perfect to get started and refactor as you go
Perfect for small resource modules
Good for small and linear infrastructure modules (eg, )
Good for a small number of resources (fewer than 20-30)
Single state file for all resources can make the process of working with Terraform slow if the number of resources is growing (consider using an argument -target to limit the number of resources)
Source:
This example contains code as an example of structuring Terraform configurations for a medium-size infrastructure which uses:
2 AWS accounts
2 separate environments (prod and stage which share nothing). Each environment lives in a separate AWS account
composition-1 {
infrastructure-module-1 {
data-source-1 => d1
resource-module-1 {
data-source-2 => d2
resource-1 (d1, d2)
resource-2 (d2)
}
resource-module-2 {
data-source-3 => d3
resource-3 (d1, d3)
resource-4 (d3)
}
}
}Each environment uses a different version of the off-the-shelf infrastructure module (alb) sourced from Terraform Registry
Each environment uses the same version of an internal module modules/network since it is sourced from a local directory.
Perfect for projects where infrastructure is logically separated (separate AWS accounts)
Good when there is no need to modify resources shared between AWS accounts (one environment = one AWS account = one state file)
Good when there is no need in the orchestration of changes between the environments
Good when infrastructure resources are different per environment on purpose and can't be generalized (eg, some resources are absent in one environment or in some regions)
As the project grows, it will be harder to keep these environments up-to-date with each other. Consider using infrastructure modules (off-the-shelf or internal) for repeatable tasks.
There is also a workshop for people who want to practice some of the things described in this guide.
The content is here - https://github.com/antonbabenko/terraform-best-practices-workshop
FTP (Frequent Terraform Problems)
Terragrunt - Orchestration tool
- Code linter
- Version manager
- A modern composable framework for Terraform backed by YAML
- HashiCorp plugin for the version manager
- Pull Request automation
- Collection of git hooks for Terraform to be used with
- Cloud cost estimates for Terraform in pull requests. Works with Terragrunt, Atlantis and pre-commit-terraform too.
Versions of resource and infrastructure modules should be specified. Providers should be configured outside of modules, but only in composition. Version of providers and Terraform can be locked also.
There is no master dependency management tool, but there are some tips to make dependency specifications less problematic. For example, can be used to automate dependency updates. Dependabot creates pull requests to keep your dependencies secure and up-to-date. Dependabot supports Terraform configurations.
Source:
This example contains code as an example of structuring Terraform configurations for a large-size infrastructure which uses:
2 AWS accounts
2 regions
- List of people who work with Terraform very actively and can tell you a lot (if you ask them).
- A community of individuals who actively share their Terraform knowledge through content, events, and open collaboration.
- Curated list of resources on HashiCorp's Terraform.
- "Your Weekly Dose of Terraform" YouTube channel by Anton Babenko. Live streams with reviews, interviews, Q&A, live coding, and some hacking with Terraform.
prod and stage which share nothing). Each environment lives in a separate AWS account and span resources between 2 regionsEach environment uses a different version of the off-the-shelf infrastructure module (alb) sourced from Terraform Registry
Each environment uses the same version of an internal module modules/network since it is sourced from a local directory.
Perfect for projects where infrastructure is logically separated (separate AWS accounts)
Good when there is no need to modify resources shared between AWS accounts (one environment = one AWS account = one state file)
Good when there is no need for the orchestration of changes between the environments
Good when infrastructure resources are different per environment on purpose and can't be generalized (eg, some resources are absent in one environment or in some regions)
As the project grows, it will be harder to keep these environments up-to-date with each other. Consider using infrastructure modules (off-the-shelf or internal) for repeatable tasks.
https://weekly.tf - Terraform Weekly newsletter. Various news in the Terraform world (projects, announcements, discussions) by Anton Babenko.

Required argument index_document must be set, if var.website is not an empty map.
Optional argument error_document can be omitted.
Use optional attributes in objects to provide default values for non-required fields:
Secrets are sensitive data that can be anything from passwords and encryption keys to API tokens and service certificates. They are typically used to set up authentication and authorization for cloud resources. Safeguarding these sensitive resources is crucial because exposure could lead to security breaches. It’s highly recommended to avoid storing secrets in Terraform config and state, as anyone with access to version control can access them. Instead, consider using external data sources to fetch secrets from external sources at runtime. For instance, if you’re using AWS Secrets Manager, you can use the aws_secretsmanager_secret_version data source to access the secret value. The following example uses write-only arguments, which are supported in Terraform 1.11+, and keep the value out of Terraform state.
Use validation blocks to ensure variables meet specific criteria:
Validate complex data structures to ensure they contain expected values:
variable "website" {
type = map(string)
default = {}
}
resource "aws_s3_bucket" "this" {
# omitted...
dynamic "website" {
for_each = length(keys(var.website)) == 0 ? [] : [var.website]
content {
index_document = website.value.index_document
error_document = lookup(website.value, "error_document", null)
}
}
}website = {
index_document = "index.html"
}variable "database_settings" {
description = "Database configuration with optional parameters"
type = object({
name = string
engine = string
instance_class = string
backup_retention = optional(number, 7)
monitoring_enabled = optional(bool, true)
tags = optional(map(string), {})
})
}# Fetch the secret’s metadata
data "aws_secretsmanager_secret" "db_password" {
name = "my-database-password"
}
# Get the latest secret value
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = data.aws_secretsmanager_secret.db_password.id
}
# Use the secret without persisting it to state
resource "aws_db_instance" "example" {
engine = "mysql"
instance_class = "db.t3.micro"
name = "exampledb"
username = "admin"
# write-only: Terraform sends it to AWS then forgets it
password_wo = data.aws_secretsmanager_secret_version.db_password.secret_stringvariable "environment" {
description = "Environment name for resource tagging"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}variable "database_config" {
description = "Database configuration"
type = object({
engine = string
instance_class = string
allocated_storage = number
})
validation {
condition = contains(["mysql", "postgres"], var.database_config.engine)
error_message = "Database engine must be either 'mysql' or 'postgres'."
}
}
variable "allowed_cidr_blocks" {
description = "List of CIDR blocks allowed to access resources"
type = list(string)
validation {
condition = alltrue([
for cidr in var.allowed_cidr_blocks : can(cidrhost(cidr, 0))
])
error_message = "All CIDR blocks must be valid IPv4 CIDR notation."
}
}Terraform’s terraform fmt command enforces the canonical style for configuration files. The tool is intentionally opinionated and non-configurable, guaranteeing a uniform format across codebases so reviewers can focus on substance rather than style. Integrate it with to validate and format code automatically before it reaches version control.
For example:
In CI pipelines, use terraform fmt -check to verify compliance. It exits with status 0 when all files are correctly formatted; otherwise, it returns a non-zero code and lists the offending files. Centralizing formatting in this way removes merge friction and enforces a consistent standard across teams.
Use .editorconfig: helps maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. Include an .editorconfig file in your repositories to maintain consistent whitespace and indentation.
Example .editorconfig:
is a framework for managing and maintaining multi-language pre-commit hooks. It is written in Python and is a powerful tool to do something automatically on a developer's machine before code is committed to a git repository. Normally, it is used to run linters and format code (see ).
With Terraform configurations pre-commit can be used to format and validate code, as well as to update documentation.
Check out the to familiarize yourself with it, and existing repositories (eg, ) where this is used already.
is a tool that does the generation of documentation from Terraform modules in various output formats. You can run it manually (without pre-commit hooks), or use to get the documentation updated automatically.
Use # for comments. Avoid // or block comments.
Example:
Section Headers: Delimit section headers in code with # ----- or ###### for clarity.
Example:
@todo: Document module versions, release, GH actions
Blog post by :
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.99.4
hooks:
- id: terraform_fmt[*]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.{tf,tfvars}]
indent_style = space
indent_size = 2
[Makefile]
indent_style = tab# This is a comment explaining the resource
resource "aws_instance" "this" {
# ...
}# --------------------------------------------------
# AWS EC2 Instance Configuration
# --------------------------------------------------
resource "aws_instance" "this" {
# ...
}Use _ (underscore) instead of - (dash) everywhere (in resource names, data source names, variable names, outputs, etc).
Prefer to use lowercase letters and numbers (even though UTF-8 is supported).
Do not repeat resource type in resource name (not partially, nor completely):
Resource name should be named this if there is no more descriptive and general name available, or if the resource module creates a single resource of this type (eg, in there is a single resource of type aws_nat_gateway and multiple resources of typeaws_route_table, so aws_nat_gateway should be named this and aws_route_table should have more descriptive names - like private, public, database).
resourcecount / for_eachtagscountDon't reinvent the wheel in resource modules: use name, description, and default value for variables as defined in the "Argument Reference" section for the resource you are working with.
Support for validation in variables is rather limited (e.g. can't access other variables or do lookups if using a version before Terraform 1.9). Plan accordingly because in many cases this feature is useless.
Make outputs consistent and understandable outside of its scope (when a user is using a module it should be obvious what type and attribute of the value it returns).
The name of output should describe the property it contains and be less free-form than you would normally want.
Good structure for the name of output looks like {name}_{type}_{attribute} , where:
{name} is a resource or data source name
outputReturn at most one ID of security group:
When having multiple resources of the same type, this should be omitted in the name of output:

Always use singular nouns for names.
Use - inside arguments values and in places where value will be exposed to a human (eg, inside DNS name of RDS instance).
Include argument count / for_each inside resource or data source block as the first argument at the top and separate by newline after it.
Include argument tags, if supported by resource, as the last real argument, following by depends_on and lifecycle, if necessary. All of these should be separated by a single empty line.
When using conditions in an argumentcount / for_each prefer boolean values instead of using length or other expressions.
list(...) or map(...).Order keys in a variable block like this: description , type, default, validation.
Always include description on all variables even if you think it is obvious (you will need it in the future). Use the same wording as the upstream documentation when applicable.
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 specific types like map(map(string)) if all elements of the map have the same type (e.g. string) or can be converted to it (e.g. number type can be converted to string).
Use type any to disable type validation starting from a certain depth or when multiple types should be supported.
Value {} is sometimes a map but sometimes an object. Use tomap(...) to make a map because there is no way to make an object.
Avoid double negatives: use positive variable names to prevent confusion. For example, use encryption_enabled instead of encryption_disabled.
For variables that should never be null, set nullable = false. This ensures that passing null uses the default value instead of null. If null is an acceptable value, you can omit nullable or set it to true.
{name} for data "aws_subnet" "private" is private
{name} for resource "aws_vpc_endpoint_policy" "test" is test
{type} is a resource or data source type without a provider prefix
{type} for data "aws_subnet" "private" is subnet
{type} for resource "aws_vpc_endpoint_policy" "test" is vpc_endpoint_policy
{attribute} is an attribute returned by the output
If the output is returning a value with interpolation functions and multiple resources, {name} and {type} there should be as generic as possible (this as prefix should be omitted). See example.
If the returned value is a list it should have a plural name. See example.
Always include description for all outputs even if you think it is obvious.
Avoid setting sensitive argument unless you fully control usage of this output in all places in all modules.
Prefer try() (available since Terraform 0.13) over element(concat(...)) (legacy approach for the version before 0.13)
`resource "aws_route_table" "public" {}``resource "aws_route_table" "public_route_table" {}``resource "aws_route_table" "public_aws_route_table" {}`resource "aws_route_table" "public" {
count = 2
vpc_id = "vpc-12345678"
# ... remaining arguments omitted
}
resource "aws_route_table" "private" {
for_each = toset(["one", "two"])
vpc_id = "vpc-12345678"
# ... remaining arguments omitted
}resource "aws_route_table" "public" {
vpc_id = "vpc-12345678"
count = 2
# ... remaining arguments omitted
}resource "aws_nat_gateway" "this" {
count = 2
allocation_id = "..."
subnet_id = "..."
tags = {
Name = "..."
}
depends_on = [aws_internet_gateway.this]
lifecycle {
create_before_destroy = true
}
}resource "aws_nat_gateway" "this" {
count = 2
tags = "..."
depends_on = [aws_internet_gateway.this]
lifecycle {
create_before_destroy = true
}
allocation_id = "..."
subnet_id = "..."
}resource "aws_nat_gateway" "that" { # Best
count = var.create_public_subnets ? 1 : 0
}
resource "aws_nat_gateway" "this" { # Good
count = length(var.public_subnets) > 0 ? 1 : 0
}output "security_group_id" {
description = "The ID of the security group"
value = try(aws_security_group.this[0].id, aws_security_group.name_prefix[0].id, "")
}output "this_security_group_id" {
description = "The ID of the security group"
value = element(concat(coalescelist(aws_security_group.this.*.id, aws_security_group.web.*.id), [""]), 0)
}output "rds_cluster_instance_endpoints" {
description = "A list of all cluster instance endpoints"
value = aws_rds_cluster_instance.this.*.endpoint
}