Best Practices and Troubleshooting
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Most of this section explains one feature at a time. This page is the opposite: it gathers the habits that experienced teams converge on, shows how to carve up an address space without arithmetic mistakes, explains how to turn on Terraform’s own logs when something goes wrong, and lists the errors that people meet most often together with what usually causes them. Each topic points to the page that covers it in depth.
The baseline is Terraform 1.17 and OpenTofu 1.13, as elsewhere in the section. The running example is Bookshelf
(catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket); every name, CIDR and identifier is a
placeholder. Error messages are quoted only where an official page quotes them; elsewhere they are abbreviated and
marked as illustrative, because wording changes between releases.
Consolidated best practices
The sources are the HashiCorp style guide, the HCP Terraform recommended practices, and the vendor guides for AWS, Google Cloud and Azure. They agree on far more than they differ, so the list below is organised by concern rather than by source.
Code layout and style
-
Run
terraform fmtandterraform validatebefore every commit, and add a linter such as TFLint. The style guide recommends exactly this trio; the tools are covered in Code Quality and Security Scanning. -
Use the conventional file names:
main.tf,variables.tf,outputs.tf,providers.tf,terraform.tf,locals.tfandbackend.tf, and split large configurations by theme (network.tf,storage.tf,compute.tf). -
Indent by two spaces, align the
=signs of consecutive single-line arguments, put meta-arguments first, then arguments, then nested blocks, thenlifecycleanddepends_on. -
Name resources with nouns and underscores, and do not repeat the resource type in the name:
aws_s3_bucket.covers, notaws_s3_bucket.covers_bucket. -
Give every variable and output a
typeand adescription, mark secretssensitive, usevalidationsparingly, and keep locals for values that are used more than once or that name an idea. -
Use
countfor near-identical resources or an on/off switch andfor_eachwhen each instance needs distinct values; preferfor_eachwith stable keys, because a list index shifts when an element is removed. -
Comment with
#, and only to explain what the code cannot say.
The layout conventions are covered in HCL Syntax and Configuration Files and Workspaces, Environments and Project Structure.
Versions and dependencies
-
Pin providers in
required_providerswith a constrained range, and setrequired_versionfor the CLI. Commit.terraform.lock.hclso every run installs the same provider builds. -
Pin registry modules to an exact or tightly bounded
version. The style guide notes that theversionargument does not apply to local modules. -
Upgrade deliberately: change the constraint, run
terraform init -upgrade, read the plan, and let Dependabot or Renovate open the pull request.
State
-
Use a remote backend with locking and encryption; never keep production state on a laptop, and keep state files out of version control. The style guide’s
.gitignoreguidance is to exclude state, saved plans, the.terraformdirectory and sensitive.tfvarsfiles, and to commit the code, the lock file and a README. -
Split state by blast radius and by rate of change (network, data stores, applications), not by team convenience.
-
Do not share whole state files; expose what other configurations need through outputs,
terraform_remote_stateor data sources. -
Treat state surgery (
state mv,state rm,import) as a reviewed change, and prefer the declarativemoved,removedandimportblocks.
Security
-
Authenticate with short-lived credentials (OIDC or dynamic provider credentials, instance or workload identity), not long-lived keys in files or variables.
-
Give the pipeline role the least privilege it needs, and use separate roles for plan and apply where the platform allows it.
-
Remember that sensitive values still land in state in plain text. Use ephemeral values and write-only arguments where the provider supports them, and a secrets manager for the rest.
-
Scan the code before apply (Checkov, Trivy) and enforce guardrails with policy as code.
Workflow
-
Make version control the source of truth, keep branches short-lived, run a speculative plan on every pull request and apply only from the main branch through automation. The recommended practices describe the progression from manual changes, through semi-automation and infrastructure as code, to collaborative infrastructure as code with HCP Terraform managing the boundaries between teams and tiers.
-
Test modules (
terraform test, Terratest) and run the tests in CI. -
Never change managed infrastructure by hand. When drift happens, either reconcile it with
terraform plan -refresh-onlyor let the next apply correct it.
Cloud vendor guidance
| Cloud | Guide | Emphasis |
|---|---|---|
AWS |
AWS Prescriptive Guidance — Best practices for using the Terraform AWS Provider |
Five sections: security, backends, code base structure and organization, AWS provider version management and community modules |
Google Cloud |
Separate pages for general style and structure, root modules, reusable modules, security and testing |
|
Azure |
Choosing between the AzureRM and AzAPI providers, state in Azure Storage with Entra ID authentication, Azure Export for Terraform |
Each vendor guide is applied in its own page: Terraform on AWS, Terraform on Google Cloud and Terraform on Azure. The Microsoft pages are tutorials and overviews rather than a single numbered best-practice document, so the Azure row summarises what that documentation set covers.
Network design with cidrsubnet
A network plan is the one piece of configuration that is expensive to change after the fact: subnets are referenced by route tables, security groups, databases and peering connections. Computing CIDR blocks by hand invites overlaps, so let Terraform do the arithmetic from a single parent block.
cidrsubnet(prefix, newbits, netnum) extends the prefix by newbits and fills the new bits with netnum;
cidrsubnets(prefix, newbits...) allocates consecutive ranges of possibly different sizes. The functions are
described in Functions. The rule the documentation gives for cidrsubnets is the
important operational one: once addresses from the result are assigned to real infrastructure, do not change the
existing newbits arguments, because that would move every later range. Append new entries instead.
The Bookshelf plan below uses a /16 for the environment and reserves a /20 block per tier, so each tier can later be
split further without touching its neighbours.
variable "vpc_cidr" {
type = string
description = "Parent CIDR block of the Bookshelf network."
default = "10.20.0.0/16"
validation {
condition = can(cidrnetmask(var.vpc_cidr))
error_message = "vpc_cidr must be a valid IPv4 CIDR block."
}
}
locals {
azs = ["a", "b", "c"]
# Three /20 tier blocks (4096 addresses each): public, private and data.
# Append new tiers at the end; never reorder or edit existing entries once deployed.
tier_blocks = cidrsubnets(var.vpc_cidr, 4, 4, 4)
tiers = {
public = local.tier_blocks[0]
private = local.tier_blocks[1]
data = local.tier_blocks[2]
}
# One /24 per tier and availability zone, carved out of each tier block.
subnets = merge([
for tier, block in local.tiers : {
for i, az in local.azs : "${tier}-${az}" => cidrsubnet(block, 4, i)
}
]...)
}
output "subnet_cidrs" {
value = local.subnets
}
With these inputs the tier blocks are 10.20.0.0/20, 10.20.16.0/20 and 10.20.32.0/20, and the first public subnet is
10.20.0.0/24. Verify any plan with terraform console before applying it.
Design guidelines that follow from this:
-
Choose the parent block so it does not overlap any network you will ever peer or connect with (other environments, on-premises ranges, other clouds). Overlaps cannot be fixed by routing.
-
Leave unallocated space. A
/16split into three/20blocks uses less than a fifth of the space on purpose. -
Keep tiers in separate blocks so security rules can match a whole tier with one prefix.
-
Use
for_eachover a map of names to CIDRs, so removing one subnet does not renumber the others; the pitfalls ofcountare in Meta-arguments. -
Provider specifics (subnets per zone on AWS, regional subnets on Google Cloud, delegated subnets on Azure) are in the three cloud pages.
Debugging with logs
Terraform has two sources of diagnostic information: the error messages it prints, and a detailed log that is off by default. The log is controlled by environment variables documented on the Debugging Terraform page.
| Variable | Meaning |
|---|---|
|
Setting it to any value turns on detailed logs on |
|
Enables logging for Terraform core only, with the same levels |
|
Enables logging for provider plugins only, with the same levels |
|
Appends the log to a file. |
A typical session isolates the layer that misbehaves:
# Everything, to a file (placeholder path)
TF_LOG=TRACE TF_LOG_PATH=./terraform-trace.log terraform plan
# Only the provider (HTTP calls and API errors), at debug level
TF_LOG_PROVIDER=DEBUG TF_LOG_PATH=./provider-debug.log terraform apply
# Only core (graph walk, state handling)
TF_LOG_CORE=DEBUG TF_LOG_PATH=./core-debug.log terraform plan
Practical advice:
-
Start at
DEBUG;TRACEis large and rarely needed. UseTF_LOG_PROVIDERfor authentication, throttling and API errors, because the provider logs the requests it makes, andTF_LOG_COREfor graph, state and evaluation problems. -
Treat log files as secrets. Provider logs can contain request and response bodies; the CI page warns against publishing
TRACElogs where many people can read them. Upload the file as an artifact only on failure, with restricted access (see CI/CD and Automation). Unset the variables afterwards:unset TF_LOG TF_LOG_CORE TF_LOG_PROVIDER TF_LOG_PATH. -
When filing a bug, the Debugging page asks you to include the detailed log, for example shared through a gist service, after removing anything sensitive.
-
OpenTofu reads the same
TF_LOGvariables; the binary istofu. -
Provider authors can add their own subsystem variables; see Provider Development.
Crash logs
If Terraform core panics, the CLI writes a crash log, normally named crash.log, in the working directory and tells you
to report the problem. A crash log contains a stack trace and can include configuration or state fragments, so review it
before sharing. The file name is the reason that the .gitignore templates in this section list crash.log and
crash.*.log (see Getting Started). A panic is always a bug, never a
configuration mistake; the fix is to upgrade, and to report it with the crash log and a minimal configuration that
reproduces it. The Debugging page does not describe crash logs, so check the file name and the conditions that
produce one on the version you run.
Narrowing a problem
-
Run
terraform validatefirst; it needs no credentials. -
Run
terraform plan -refresh=falseto separate configuration problems from provider read problems. -
Use
terraform consoleto evaluate an expression in the context of the current state. -
Use
terraform state listandterraform state show ADDRESSto see what Terraform believes exists. -
Use
terraform graphto understand ordering and cycles (see Dependency Graph and Plan). -
Reproduce with the smallest configuration possible; most bugs disappear or become obvious when everything else is removed.
The commands are in CLI Essentials.
Common errors and fixes
The messages below are abbreviated and illustrative unless stated; search for the key phrase, not the whole text.
| Symptom | Usual cause | Fix |
|---|---|---|
An "Error acquiring the state lock" message |
Another run holds the lock, or a crashed runner or killed process left it behind |
Wait for the other run. If you are certain nothing is running, |
The lock file does not match the installed providers ("Inconsistent dependency lock file") |
|
Run |
Provider not found or version constraint not satisfied during init |
Wrong source address, constraint that excludes every release, or a registry or mirror that cannot be reached |
Check |
Authentication or permission errors (HTTP 401 or 403) |
Missing or expired credentials, wrong role, wrong subscription or project |
Reproduce with the cloud CLI, then check the identity Terraform actually uses with |
"Invalid for_each argument" or "Invalid count argument", mentioning values that cannot be determined until apply |
The collection depends on an attribute only known after apply |
Build keys from values known at plan time (names, variable inputs), not from generated IDs; see Meta-arguments |
A "Cycle" error |
Two objects reference each other, directly or through a chain |
Break the loop, usually by removing the unnecessary reference or moving one attribute to a separate resource; |
"Instance cannot be destroyed" |
|
Remove the argument deliberately, or change the plan; see Lifecycle, Validation and Checks |
A plan wants to replace a resource you did not touch |
An argument that forces replacement changed, a rename without a |
Read which attribute is marked as forcing replacement; add a |
A resource exists in the cloud but Terraform wants to create it |
It was created outside Terraform, or state was lost |
Bring it under management with an |
Plan shows changes every run |
Drift, an attribute normalised by the API, or a non-deterministic value such as |
Run |
A "Provider produced inconsistent result after apply" message |
A provider bug |
Upgrade the provider, retry, and report it with a debug log; it is not a mistake in your configuration |
Backend initialisation errors after changing the backend block |
The backend configuration changed |
|
Variables not allowed in a backend block |
Backend configuration is read before variables are evaluated |
Use partial configuration with |
Sensitive value appears in output or logs |
Output not marked sensitive, or logs enabled in CI |
Mark the output |
Source: Terraform Docs — References to values and the Terraform CLI command pages cited in the pages linked from the table, for example terraform force-unlock and terraform init.
Reading a plan before applying
Most expensive mistakes are visible in the plan, and unnoticed. Check the summary line for unexpected destroy or replace counts, look for the "forces replacement" annotation on the attributes, look at what is (known after apply) and ask whether it should be, and read changes to IAM, network rules and anything with data. Save the plan (-out) and apply that file, so what was reviewed is what runs.
Review checklist
-
terraform fmt -check,validate, TFLint and a security scanner pass in CI. -
Providers, modules and the CLI are pinned; the lock file is committed.
-
State is remote, locked, encrypted and split by blast radius.
-
No credentials, account IDs or state files in the repository.
-
Every variable and output has a type and a description; secrets are sensitive.
-
Network ranges come from one parent block and leave room to grow.
-
A saved, reviewed plan is what gets applied, and only by the pipeline.
-
Log variables are unset in routine runs.