Notes from terraform-best-practices.com, an open book (Apache 2.0) maintained by Anton Babenko with contributors and translated into many languages. It separates established practices from opinionated ones. Basics are in Terraform and the wider picture in Infrastructure as Code.
The book's pages: Key concepts, Code structure, Code structure examples (Terragrunt and Terraform: small, medium and large), Naming conventions, Code styling, Writing Terraform configurations, FAQ, References and a Workshop.
Key Concepts
flowchart TD
C[Composition: whole infrastructure for an org or project] --> I1[Infrastructure module: one region or project]
C --> I2[Infrastructure module: another region or account]
I1 --> R1[Resource module, e.g. terraform-aws-vpc]
I1 --> R2[Resource module, e.g. terraform-aws-security-group]
R1 --> RES[Resources: aws_vpc, aws_subnet, aws_nat_gateway]
| Concept | Meaning |
|---|---|
| Resource | One infrastructure object such as aws_vpc or aws_db_instance; has arguments, attributes and a lifecycle |
| Resource module | A group of connected resources doing one common job, e.g. a VPC module creating VPC, subnets and NAT gateway. The smallest versioned, shareable unit |
| Infrastructure module | A group of resource modules serving one purpose in a setup; defines provider configuration and usually covers one logical boundary (an AWS Region or a Google Cloud project) |
| Composition | A collection of infrastructure modules, possibly spanning regions or accounts: the full infrastructure |
| Data source | Read-only lookup. Useful ones: terraform_remote_state (link layers), external (run a program), http (HTTP GET when no provider exists) |
| Remote state | State for each infrastructure module and composition kept in a remote backend |
Mental model: resources are the atoms, resource modules the molecules.
Data flow: between resource modules and infrastructure modules via module outputs and data sources; between compositions mainly via remote state data sources.
Code Structure
There is no universal answer, so ask first:
- Complexity: number of resources and providers (logical providers like
random,local,null,timedo not count much) - Change frequency: monthly, daily, or every commit
- Who changes it: only developers, or anyone and CI via pull requests
- Deployment platform: CodeDeploy, Kubernetes, OpenShift and so on
- Grouping: by environment, region or project
Starting file layout
| File | Contents |
|---|---|
main.tf |
Module calls, locals and data sources that create resources |
variables.tf |
Variable declarations used in main.tf |
outputs.tf |
Outputs from resources created in main.tf |
versions.tf |
Version requirements for Terraform and providers |
- Everything in
main.tfis fine for examples and getting started terraform.tfvarsshould only be used in a composition
Common recommendations
- Smaller is faster:
planandapplycall cloud APIs to check every resource, so one huge composition is slow - Limit the blast radius: unrelated resources in separate compositions are insulated from each other, which also reduces the damage of a security breach
- Use remote state from the start: a laptop is not a source of truth and a
tfstatein git is a "nightmare"; configure the backend with ACLs, versioning and logging - Be consistent in structure and naming: code is written for people to read first, and moving resources in state is harder when layout is inconsistent
- Keep resource modules plain
- Do not hardcode values that can be variables or looked up with data sources
- Use data sources and
terraform_remote_stateas the glue between infrastructure modules in a composition
Orchestrating modules and compositions
As infrastructure grows you must chain runs and pass values between modules. Options the book lists:
| # | Approach | Notes |
|---|---|---|
| 1 | Terraform only | Simple, only Terraform knowledge needed |
| 2 | Terragrunt | Pure orchestration for the whole infrastructure; reduces duplication, works natively with modules and compositions |
| 3 | In-house scripts | Often a starting point before finding Terragrunt |
| 4 | Ansible or similar | Common when Ansible came first |
| 5 | Crossplane and Kubernetes-inspired tools | Reconciliation loop to reach desired Terraform state |
The book reviews options 1 and 2 only.
Examples by size
| Size | Description | Terraform | Terragrunt |
|---|---|---|---|
| Small | Few resources, no external dependencies, one account, one region, one environment | Ready | n/a |
| Medium | Several accounts and environments, off-the-shelf infrastructure modules | Ready | Adds a composition pattern |
| Large | Many accounts and regions, less copy-paste, custom modules, heavy use of compositions | In progress | Planned |
| Very large | Multi-cloud with several providers | Planned | Planned |
Naming Conventions
These apply to Terraform identifiers; cloud provider restrictions can override them for real resource names.
General
- Use
_(underscore) not-in resource, data source, variable and output names - Prefer lowercase letters and numbers
- Use
-inside argument values and anywhere a human sees the value (e.g. an RDS DNS name)
Resources and data sources
- Do not repeat the resource type in the name:
resource "aws_route_table" "public" {} # preferred
resource "aws_route_table" "public_route_table" {} # avoid
resource "aws_route_table" "public_aws_route_table" {} # avoid
- Name it
thiswhen nothing more descriptive fits or the module creates one resource of that type (the AWS VPC module'saws_nat_gatewayisthis, while route tables arepublic,private,database) - Use singular nouns
Argument order inside a resource
countorfor_eachfirst, then a blank line- Regular arguments
tags(if supported)depends_on, thenlifecycleif needed- One blank line between groups
resource "aws_nat_gateway" "this" {
count = 2
allocation_id = "..."
subnet_id = "..."
tags = {
Name = "..."
}
depends_on = [aws_internet_gateway.this]
lifecycle {
create_before_destroy = true
}
}
- In
count, prefer a boolean condition over alength()check:
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
}
Variables
- Reuse the
name,descriptionanddefaultfrom the provider's Argument Reference; do not invent new ones - Plural names for
list(...)andmap(...)variables - Key order:
description,type,default,validation - Always include
description - Prefer simple types (
string,number,list(...),map(...),any) overobject()unless you need strict per-key constraints; use specific types likemap(map(string))when elements share a type - Use positive names (
encryption_enabled, notencryption_disabled) - Use
tomap(...)for maps, because{}can be a map or an object nullable = falsefor variables that must never benull(passingnullthen falls back to the default)- Validation is limited before Terraform 1.9 (cannot reference other variables)
Outputs
- Name them for the property returned with the pattern
{name}_{type}_{attribute}, e.g.{name}= the resource name,{type}= the type without the provider prefix,{attribute}= what is returned - Plural name for list outputs
- Always include
description - Avoid
sensitiveunless you control every place the output is used - Prefer
try()(Terraform 0.13+) over the legacyelement(concat(...))pattern
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, "")
}
The book does not set naming rules for modules or files.
Code Styling
- Format with
terraform fmt(opinionated, not configurable); enforce it with the pre-commit hookterraform_fmtandterraform fmt -checkin CI (non-zero exit lists unformatted files) - Add an
.editorconfig: spaces, size 2, trim trailing whitespace, for*.tfand*.tfvars; tabs forMakefile - Pre-commit hooks for validity, formatting and documentation before code is pushed
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.99.4
hooks:
- id: terraform_fmt
- Generate docs with terraform-docs, manually or through pre-commit
- Examples and modules should include documentation; use absolute links in
README.mdso the Terraform Registry renders them - Diagrams with Mermaid, blueprints with cloudcraft.co
- Comments: use
#, avoid//and block comments; mark section headers with# -----or######lines
Writing Terraform Configurations
Dependencies with locals
Use locals to hint at destroy order when Terraform sees no direct dependency between resources.
Optional nested blocks with dynamic and lookup
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)
}
}
Optional object attributes (Terraform 1.3+)
variable "database_settings" {
type = object({
name = string
backup_retention = optional(number, 7)
})
}
Secrets
- Keep secrets out of configuration and state: anyone with repository access could read them
- Fetch at runtime with data sources such as
aws_secretsmanager_secret_version - On Terraform 1.11+, use write-only arguments (e.g.
password_wo): sent to the provider and never stored in state
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = data.aws_secretsmanager_secret.db_password.id
}
resource "aws_db_instance" "example" {
password_wo = data.aws_secretsmanager_secret_version.db_password.secret_string
}
Variable validation
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
For complex types, check object attributes or every item in a list, e.g. alltrue() with can(cidrhost(...)) to confirm each entry is a valid CIDR.
FAQ Highlights
Tools to consider
| Tool | Purpose |
|---|---|
| Terragrunt | Orchestrates Terraform runs |
| tflint | Lints Terraform code |
| tfenv / asdf-hashicorp | Manage Terraform versions |
| Atmos | Composable framework with YAML configuration |
| Atlantis | Automates Terraform workflows in pull requests |
| pre-commit-terraform | Git hooks via the pre-commit framework |
| Infracost | Cost estimates for changes in pull requests |
Dependency hell with modules
- Version resource and infrastructure modules
- Configure providers outside modules, only in the composition
- Lock provider and Terraform versions
- Use Dependabot to open pull requests for updates
Putting It Together: A Practical Layout
infrastructure/
├── modules/ # resource modules (versioned, plain)
│ ├── network/
│ └── app/
├── compositions/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── versions.tf
│ │ └── terraform.tfvars # tfvars only in a composition
│ └── prod/
│ └── ...
└── .pre-commit-config.yaml
Checklist from the book's recommendations:
1. Remote state with locking, versioning and logging from day one
2. Small compositions to limit blast radius and speed up plans
3. Consistent naming (this, singular, underscores)
4. description on every variable and output
5. terraform fmt, tflint and terraform-docs in pre-commit and CI (CI-CD Pipelines)
6. Pinned module and provider versions, no secrets in state
7. Plan on pull requests (Atlantis, GitHub Actions) and estimate cost with Infracost
Additional points I would add beyond the book: use
for_eachovercountfor collections, commit.terraform.lock.hcl, and scan code with Checkov / Trivy.