50 Terraform Lessons Most Teams Learn Too Late
Terraform codebases rarely fail on day one. They fail on day 400. Fifty lessons from Terraform in Depth, fact-checked against current Terraform and OpenTofu, with code and a 20-point checklist.
Key takeaways
- Most Terraform failures come from state, versioning and delivery, not HCL syntax.
- Keep secrets out of state with ephemeral values and write-only arguments, lock S3 state with use_lockfile instead of DynamoDB, refactor with moved blocks, and pin exact versions only in root modules.
- Apply saved plans from CI using OIDC, never from laptops.
On this page
Terraform codebases rarely fail on day one. They fail on day 400, when nobody dares run apply, the state file is a minefield, and "just rename that resource" turns into a production outage. The fix is mostly discipline around state, versioning, testing and delivery. These are the 50 lessons that get you there.
Verified against
Terraform 1.17, OpenTofu 1.10 and AWS provider v6, against the developer.hashicorp.com S3 backend and write-only argument pages, the Terraform 1.10 changelog and the opentofu.org state encryption docs. Behaviour claims marked "tested" were run on OpenTofu 1.10.6. Checked November 2026.
I just finished Robert Hafner's Terraform in Depth (Manning, February 2025, 504 pages). It's not a beginner tutorial. It's a field manual for the gap between "works on my laptop" and "40 engineers ship infrastructure through it every day without fear."
I work in DevSecOps and software supply chain security, so I read it with one question in mind: what do teams get wrong, and what does it cost them?
Below are the 50 lessons I pulled out. A few notes before you start:
- Everything is fact-checked against current releases. The book went to print in early 2025, and Terraform has moved since. Where it has, I say so.
- A couple of popular claims didn't survive testing. I tested them against OpenTofu 1.10, which shares Terraform's language engine. Those are corrected too.
- It's long on purpose. Save it, skim the headings, come back to the code.
Part 1: Terraform thinks in graphs, not files
1. Terraform doesn't read your files. It builds a graph.
Your code becomes a directed acyclic graph (DAG) of resources. Plan, apply and destroy are all walks over that graph. Splitting code into network.tf and iam.tf is for humans; moving a resource between files should give you an empty plan.
2. Dependencies come from references.
When one resource reads another's attribute, that's an edge. Use depends_on only for real ordering needs with no data link, like a NAT gateway that needs the internet gateway up first.
3. Modules aren't execution boundaries.
The graph is built from resources, not modules. If module B uses an output of module A, some of B's resources can still be created before all of A's finish. That explains a lot of "why did it create that first?" surprises.
4. Always apply a saved plan.
terraform plan -out tfplan
terraform apply tfplan # applies exactly what you reviewed, no prompt
Running plan and then apply -auto-approve creates a new plan. If anything changed in between, you apply something you never reviewed.
5. Search every plan for # forces replacement.
That comment names the exact attribute that turns a harmless update into destroy-and-recreate. One changed key algorithm can cascade into replacing every certificate signed by it. If the resource is a database, read what to do when a plan wants to replace production before you touch anything.
6. Don't read execution order from plan output.
Plan output is sorted by resource address, not by the order things will run. I tested this: a resource that runs last was listed first because its name sorted first. To see real ordering, use the graph:
terraform graph | dot -Tsvg > graph.svg
It's also a quick architecture diagram to hand a security reviewer. Need machine-readable plans for policy or cost tools? terraform show -json tfplan.
Part 2: State is the part everyone underestimates
7. sensitive = true encrypts nothing.
It only hides values from CLI output and logs. Regular secret values (every random_password, every database password, every secret read through a data source) are still written to state in plain text. Hafner's line stuck with me: the only thing worse than losing your state is leaking it. Even with zero secrets, state is a full map of your architecture.
8. Modern versions give you real ways to keep secrets out of state.
This is the biggest update since the book. Terraform 1.10 added ephemeral values, which are never written to plan or state. Terraform 1.11 added write-only arguments, so managed resources can accept them:
ephemeral "random_password" "db" {
length = 24
}
resource "aws_db_instance" "main" {
# ...
password_wo = ephemeral.random_password.db.result
password_wo_version = 1 # bump to rotate
}
On OpenTofu, client-side state encryption (since 1.7) encrypts state at rest with a passphrase or a KMS key.
9. Judge your backend on three things: resiliency, security, availability.
Lose state and you're re-importing everything. Leak it and you've leaked your architecture. And if the backend is down, nobody can deploy, including during an incident. Turn on versioning, encryption, access logging and locking, restrict access, and keep independent backups. Hafner's rule: if you haven't tested a restore recently, your backups are worthless.
10. You no longer need DynamoDB to lock S3 state.
The book predates this. Native S3 locking (use_lockfile = true) arrived in Terraform 1.10, and HashiCorp's docs now mark DynamoDB-based locking as deprecated, to be removed in a future minor version.
terraform {
backend "s3" {
use_lockfile = true # bucket, key, region via -backend-config
}
}
Keep the real values out of code with partial configuration: terraform init -backend-config=prod.s3.tfbackend. If a lock does get stuck, clearing a Terraform state lock safely walks through it.
11. Refactor state in code, not in the CLI.
moved, import and removed blocks show up in the plan, get reviewed, and apply the same way in every environment.
moved {
from = aws_instance.hello_world
to = aws_instance.this
}
removed {
from = aws_s3_bucket.legacy
lifecycle {
destroy = false # stop managing, don't delete
}
}
12. Delete import blocks after they run. Keep moved blocks forever.
Leftover imports make code awkward to reuse in a fresh environment. A moved block is inert once applied, and deleting one breaks anyone upgrading from an old version of your module.
13. Treat -target, -refresh=false and terraform refresh as incident tools.
In Hafner's words, a module that needs -target to work is "a massive antipattern." If you target in batches because applies are slow, the project is too big: split it. terraform refresh rewrites state without review; if credentials expire mid-refresh, resources can vanish from state and get duplicated on the next apply. Use terraform apply -refresh-only, which shows you the changes first. And terraform taint is replaced by terraform plan -replace=ADDRESS, which shows the blast radius before anything happens.
14. Drift is a symptom. Ask why before you apply.
Hafner sorts drift into four kinds:
- Accidental console change. Fix the system (access, CI), not the person. A normal apply reverts it.
- Intentional hotfix at 3 a.m. Don't run Terraform until the fix is in code, or you'll undo it.
- Conflicting automation (autoscaling counts, tags added by EKS, new AMIs). Decide: apply it, or
ignore_changesit. - Terraform failure (crash, expired credentials mid-apply). Orphaned resources get duplicated. Import or delete them, and restore corrupt state from backup.
The mechanics of each fix are in detecting and fixing Terraform drift. One more state rule: when projects need each other's values, prefer native data sources, then input variables, and only then terraform_remote_state. Remote state read access exposes every value in that state.
Part 3: The language features that separate clean code from spaghetti
15. Declare every provider in required_providers.
Terraform will guess that aws_instance means hashicorp/aws. Without an explicit declaration you can't pin a version, and you've built a time bomb.
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}
16. Adopt a block-ordering convention.
terraform fmt aligns = signs but never reorders arguments. The book's convention: meta-arguments first (provider, count, for_each), then regular arguments, then nested blocks, then lifecycle last. Reviewers will thank you.
17. prevent_destroy is weaker than you think.
It only accepts a literal, so you can't turn it on for prod and off for dev. It breaks destroy plans for ephemeral environments. And the most common way resources get destroyed is someone deleting the block, which deletes the protection with it. ignore_changes is the lifecycle setting you'll actually use daily: ignore ami so new images don't replace running servers, and ignore tags or desired counts other systems own.
18. replace_triggered_by can't reference a variable. Wrap it.
resource "terraform_data" "type_marker" {
input = var.instance_type
}
resource "aws_instance" "app" {
# ...
lifecycle {
replace_triggered_by = [terraform_data.type_marker]
}
}
19. Validation is the hallmark of a great module.
Plans are slow and some errors only appear at apply time, so reject bad input up front. Since Terraform 1.9, a validation can reference other variables as long as it still references its own. I confirmed this on OpenTofu 1.10.
variable "replicas" {
type = number
validation {
condition = var.replicas >= 0 && floor(var.replicas) == var.replicas
error_message = "Replicas must be a non-negative whole number."
}
}
Type gotchas worth memorising: there's one number type for integers and floats; object types silently drop keys you didn't declare (tested); and optional(number, 2) gives object attributes clean defaults.
20. Five expression traps.
"15" == 15is false. Equality doesn't convert types, even though most of Terraform does. (Tested.)uuid()ortimestamp()in a resource argument means a dirty plan forever. Use therandomandtimeproviders.regex()errors on no match;regexall()never does.length(regexall(...)) > 0is the safe check.can()belongs in validation blocks. Elsewhere you probably wanttry(). Never use either to hide real errors.- Never template JSON or YAML strings. Build an object and call
jsonencode(); for IAM, useaws_iam_policy_document.
21. count vs for_each: pick by identity.
countis positional ([0],[1]). Great for on/off toggles. Remove an item from the middle of a list and everything after it is reindexed and recreated.for_eachis keyed (["api"],["db"]). Use it for distinct things with stable names. Wrap lists intoset().
Both must be known at plan time: no resource attributes, no uuid(). Hafner's workaround is elegant: use count with a number you do know, and index the possibly unknown list with element(var.subnet_ids, count.index). The errors you hit when you get this wrong are in count vs for_each, and the errors.
22. Small tricks with outsized payoff.
var.sg_id[*]turns one value into a list, andnullinto an empty list.{ for i in aws_instance.app : i.subnet_id => i.id... }groups duplicates into lists.for_each = var.enabled ? [1] : []toggles a singledynamicblock.- Expose booleans, not counts:
enable_ssm = truebeatsssm_count = 1. - Try expressions in
terraform consolebefore you commit them.
Part 4: Module design that survives a real team
23. A module is a function.
Inputs are arguments, outputs are return values, locals are internal variables. Give every input a type and a description, and the rest of the design rules follow.
24. Provider blocks belong only in root modules.
Reusable modules declare required_providers and nothing else. Also: don't nest submodules inside submodules, and name repos terraform-<PROVIDER>-<NAME>.
25. Design the interface for the second use case.
- Take a
name_prefix, not aname, so the module can create several resources later. - Merge tags so the module wins where it must:
merge(var.tags, { Name = local.name }). - Don't try to predict every IAM policy. Create the role, output it, let callers attach policies.
- Say so in the description when changing an input (a CIDR, an AZ count) destroys everything.
26. Put random suffixes inside the module.
Callers shouldn't have to think about them. Some names stay reserved after deletion: a deleted AWS Secrets Manager secret keeps its name for its recovery window, 30 days by default (7 at minimum). S3 bucket names also benefit from randomness to defend against bucket squatting. For names, the book prefers random_string (lowercase, no special characters) over random_password, which would mark your resource names as sensitive.
27. Mirror the module tree in your names.
The top module hardcodes the app and takes environment; each child appends a short suffix.
locals {
base_name = "acme-${var.environment}"
}
module "api" {
source = "./service"
name = "${local.base_name}-api" # -> acme-dev-api-cluster, acme-dev-api-logs
}
Keep segments short (db, not database) and leave the resource type out of the name.
28. Wide ranges in reusable modules. Exact pins in root modules.
This is the inversion most teams get backwards. Reusable modules should accept the widest range that works (~> 6.0), or consumers hit version conflicts when combining modules. Root modules pin exact versions (version = "1.4.2"), so every upgrade is a reviewed PR you roll out environment by environment. Commit .terraform.lock.hcl so every machine installs the same provider builds. How to run that at scale is in Terraform module versioning with shared registries.
29. Vet public modules like any dependency.
Git sources can't use version, only ?ref= pins, which is why the book recommends a registry once you scale. Before adopting a community module, check how often it's updated, whether it promises backward compatibility, whether one person or a group maintains it, and whether anything in the code looks odd. A module is code that runs with your cloud credentials.
Part 5: Testing and refactoring without fear
30. The real payoff of tests is psychological safety.
A team with a test suite stops being afraid of its own codebase and can take on big refactors. That matters more than the bugs it catches.
31. Don't test the provider. Test your logic.
Asserting that the instance type you passed is the instance type you got is worthless; the provider is already tested. Test what you wrote: string building, transformations, regexes, dynamic blocks with zero, one and many items, and real behaviour (does the endpoint answer, do the generated credentials log in?).
32. Most Terraform tests are integration tests. Accept it.
Terraform's whole job is configuring external systems, so unit testing is hard. Group many assertions under one deployed configuration, because a database alone can take 30+ minutes to create. Mocks help, but they supplement real tests, never replace them.
33. Make your examples/ your test fixtures.
The author's favourite pattern. Every module ships working examples (basic, ecs, lambda...). They're user documentation, a developer sandbox, and test fixtures in one. Your tests then guarantee your docs work.
34. The native test framework can see what Terratest can't.
terraform test (Terraform and OpenTofu 1.6+) can assert on any named value, even locals, without exposing it as an output. With mocks (1.7+), logic tests run in seconds and cost nothing:
mock_provider "aws" {}
run "name_includes_region" {
command = plan
override_data {
target = data.aws_region.current
values = { region = "us-east-1" } # AWS provider v6 uses region, not name
}
assert {
condition = aws_route53_record.main.name == "api.us-east-1.example.com"
error_message = "Record name should include the region."
}
}
Watch for two things: plan-mode asserts on values only known after apply fail with Unknown condition value, and mocks drift from reality, so review them on every provider major. Terratest (Go) is still the de facto standard, so keep it if you have it or support older versions. Starting fresh? The book recommends the native framework.
35. Hard-won operational rules for test pipelines.
- Randomise names (
random_string, length 8, lowercase) so parallel PRs don't collide. - Set timeouts at two levels. Go's test timeout defaults to 10 minutes; pass
-timeout 60m. A timeout kills the run before destroy, and orphans start billing. - Run a scheduled cleanup tool such as aws-nuke, only in dedicated test accounts. Never near production.
- Fan out slow tests with a CI matrix: four 45-minute database tests take 3 hours in series and 45 minutes in parallel.
- Treat AI-written tests as drafts and have someone who knows the language review them.
36. Budget about 20% of time for refactoring.
Modules rot relative to the ecosystem: code written before validation blocks existed looks sloppy today without a line changing.
- Internal refactors must never break tests. If no input or output changed, the bar is an empty plan and green tests.
- Rename variables with expand-and-contract: add the new one, keep the old one with a
DEPRECATEDdescription, read both through a local, delete the old one in the next major. - Batch breaking changes. Aim for at most one major release a year, ideally alongside a provider major when users expect breakage anyway. Security fixes ship immediately.
- Document every break in
UPGRADE.mdas it lands, and backport bug fixes to the previous major.
Part 6: CI/CD, GitOps and project layout
37. Build the quality system before the CI system.
CI doesn't do much on its own; it runs whatever the project already has. Wrap every command in a Makefile, run it locally, and have CI call the same targets. Local and CI stay identical, and nobody memorises a dozen flags.
# Makefile (override with: make format TF_BINARY=tofu)
TF_BINARY ?= terraform
format:
$(TF_BINARY) fmt -recursive .
test_format:
$(TF_BINARY) fmt -check -recursive .
security: test_checkov test_trivy
38. The toolchain, layer by layer.
fmtandvalidateon every commit (init -backend=falsefirst, so it never touches real state).- TFLint with cloud rulesets. The book recommends the
allpreset, then disabling what you don't want. - Checkov and Trivy for misconfigurations. Run both; their rules differ.
- terraform-docs in inject mode, with
--output-checkfailing CI when docs are stale. - pre-commit so formatting errors are caught at commit, not after a push.
- tenv to pin Terraform, OpenTofu and Terragrunt versions per repo.
- Dependabot or Renovate to propose provider, module and action upgrades as PRs.
Put all of it in a template repo, so every new module has CI before it has code. One deliberate exception: keep tflint --fix out of automated chores, because some "best practice" fixes are wrong for your system.
39. Automate the boring review so humans review what matters.
Machines handle style, lint and known misconfigurations. Humans review three things: the infrastructure itself (a database with backups off is valid code a scanner may miss), consistency with your other modules, and documentation. If a reviewer has to ask what something does, the answer belongs in a comment. Turn on branch protection early; on infra repos, the required approval is a security control.
40. Stop applying from laptops.
State locking stops two applies at once. It doesn't stop Alice and Bob overwriting each other because each is missing the other's code. The flow: branch, open a PR, CI runs and posts a speculative plan, review, merge, deploy from main. And schedule plans: if plans only run on PRs, drift goes unnoticed. Terraform drift detection in CI shows the scheduled job.
41. One thin root module per environment.
A single root with dev.tfvars and prod.tfvars means every environment always runs the same code version, so you lose staged rollouts. Instead, package the app as one module and give each environment a thin root pinned to an exact version:
module "application" {
source = "registry.example.com/acme/aws/application"
version = "1.4.2"
db_size = 20
num_tasks = 5
}
Promotion becomes a one-line PR. Isolate environments as far as you can (separate accounts, networks, databases), and never let one environment depend on another of the same app.
42. Choose a delivery platform for what it supports, not just for Terraform.
Use the CI you already have; Terraform isn't special there. For CD, the book compares HCP Terraform, Spacelift, env0, Scalr, Atlantis, Digger, Terrateam, Terrakube, Harness and Octopus Deploy. Hafner's rules of thumb: prefer platforms that support more than one IaC tool, enforce policy on the plan (code scanners can't see values passed in as variables, so standardise on OPA), and keep external state backups even when the platform manages state. If you self-host, the deploy system must not depend on the infrastructure it manages. He's openly critical of HCP Terraform's per-resource pricing, which he argues penalises well-factored infrastructure. That's his opinion; run your own numbers, starting with our comparison of Terraform Cloud alternatives.
Part 7: Infrastructure code is a supply chain
Providers are binaries that run with your cloud credentials. Modules are third-party code. The pipeline that applies them holds the keys to production. Infrastructure code is a supply chain.
43. The best secret is the one you don't have: use OIDC.
No long-lived cloud keys in CI. Register your CI's identity provider once per account, then let a role trust it. The part people skip is the subject condition:
data "aws_iam_policy_document" "ci_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:acme/infra:ref:refs/heads/main"]
}
}
}
Without the sub condition, any GitHub repository in the world can request a token from that identity provider and assume your role.
44. Pass the path, not the value.
Every way of feeding a secret into Terraform leaks somewhere: -var ends up in shell history, .tfvars in Git, environment variables in logs. In order of preference:
- Machine-to-machine delivery. Give the runtime a reference and let it fetch the secret, e.g. pass ECS the ARN of a Secrets Manager secret. Terraform never sees the value.
- Ephemeral resources and write-only arguments (Terraform 1.10/1.11) when Terraform must handle the value.
- Read from a secret manager with a data source, knowing the value lands in state.
- Secrets stored in your CI or TACOS, as a last resort. The 2021 Travis CI incident, which exposed secrets for public repositories, is a reminder that the orchestrator is attack surface too.
Never commit credentials, even to a private repo. And note that terraform login stores registry tokens in plain text in ~/.terraform.d/credentials.tfrc.json.
45. Scan twice, suppress with a reason.
IaC lets you catch misconfigurations before they ship. Run Checkov and Trivy together; both are free and their rules differ. The book also names commercial options (Snyk, Checkmarx and Mend; disclosure: I work at Mend.io) for cross-project dashboards and SBOMs. The rule for findings: fix by default, suppress only intended behaviour, and always write down why.
resource "aws_s3_bucket" "site" {
#checkov:skip=CKV_AWS_20:Public static website by design
bucket = local.bucket_name
}
For org-wide rules (no GPU instance families, no gp2 volumes), Checkov's YAML custom policies are far easier than Rego. Keep them in one central repo.
46. Design for a small blast radius.
- Remove production credentials from laptops. If CI deploys production, nobody needs to run
terraform destroyagainst it locally. You can't destroy what you can't reach. - Restrict state and remote-state access to the deployment system, and log it.
- Pin and verify the supply chain: explicit provider sources and constraints, a committed lock file, exact module pins in roots, upgrades via reviewed PRs. A lock file is reviewable code too, as the poisoned Terraform lock file attack showed.
- Use the right guardrail for the job. Variable validation rejects bad inputs.
precondition/postconditionblock a run, e.g. refusing a deprecated AMI.checkblocks only warn, which suits ongoing health checks:
check "health" {
data "http" "api" {
url = var.healthcheck_url
depends_on = [module.api] # otherwise it runs before anything exists
}
assert {
condition = data.http.api.status_code < 300
error_message = "API health check failed."
}
}
Part 8: OpenTofu, and when not to use Terraform
47. Terraform vs OpenTofu, without the drama.
HashiCorp moved Terraform to the Business Source License starting with 1.6 in 2023. The community forked the last open-source release as OpenTofu, which now sits under the Linux Foundation and was accepted into the CNCF as a sandbox project in April 2025. HashiCorp itself is now part of IBM. In practice:
- They're still highly compatible. Hafner ran every example in the book on both with no changes.
- They're diverging. OpenTofu has state encryption; Terraform has its own new features. The native test framework is already one of the places they differ most.
- Packaging changed. Many open-source package repositories, including Homebrew core, stopped at Terraform 1.5.7, the last open-source release.
- Hedge cheaply. Test both engines in CI with a matrix. OpenTofu 1.8+ also reads
.tofufiles and prefers them over a.tffile of the same name, while Terraform ignores them: one codebase, both engines.
48. Provisioners are a last resort.
The book says so bluntly. remote-exec, local-exec and file add failure points, slow deploys, behave unpredictably and bake network details into modules. Use instead:
- Software installation: bake it into an image with Packer.
- Boot-time configuration and files: Cloud-Init (
cloudinit_config). - Shutdown steps: a service in the image.
- Local metadata: outputs or the
localprovider.
If you truly need one, host it on terraform_data, the built-in replacement for null_resource. The same "last resort" label applies to the external data source.
49. Terraform consumes artifacts. It doesn't build them.
Don't build container or machine images in Terraform: it slows every deploy and couples app and infrastructure. Build images in their own pipeline, tag them, and pass the tag in as a variable; rollback becomes a Git revert. And don't deploy workloads onto Kubernetes with the Kubernetes provider: Terraform is great for building clusters, while Helm, Argo CD or kubectl from CD are better for what runs on them.
50. Learn the concepts, not just the syntax.
Hafner was skeptical that CDKTF saved ordinary teams time, because most of learning Terraform is state, delivery and testing, not HCL. History agreed: HashiCorp archived CDKTF on December 10, 2025, recommending a migration to standard Terraform and HCL. Wrapping the CLI is still a great option for building tools: -json output and terraform show -json on a saved plan power Checkov, Trivy, Atlantis and every delivery platform, and you can build your own plan scanner in an afternoon.
What changed since the book went to print
A great book is still a snapshot. Here's what I corrected or updated while writing this, so you don't learn it the hard way.
| Topic | What older material says | What's true now | Since |
|---|---|---|---|
| S3 state locking | Needs a DynamoDB table | use_lockfile = true; DynamoDB locking deprecated | Terraform 1.10 |
| Secrets in state | Unavoidable for managed resources | Ephemeral values and write-only arguments | Terraform 1.10 / 1.11 |
| State encryption | Backend-side only | Client-side encryption in OpenTofu | OpenTofu 1.7 |
| CDKTF | Supported alternative to HCL | Archived, migrate to HCL | December 10, 2025 |
data.aws_region | .name attribute | .region attribute | AWS provider v6 |
| GitHub OIDC on AWS | Pin certificate thumbprints | No pinning needed | July 2023 |
Updated: things that moved
- S3 locking no longer needs DynamoDB. Native S3 locking arrived in Terraform 1.10, and DynamoDB-based locking is now marked deprecated in HashiCorp's docs.
- Secrets can stay out of state. Ephemeral values (1.10) and write-only arguments (1.11) fill the gap the book describes. OpenTofu has had state encryption since 1.7.
- CDKTF is gone. HashiCorp deprecated it and archived the repository on December 10, 2025.
- OpenTofu joined the CNCF as a sandbox project in April 2025, and HashiCorp is now an IBM company.
- AWS provider v6 deprecated
data.aws_region'snameattribute in favour ofregion, which affects older mocks and examples. - GitHub OIDC with AWS hasn't required pinning certificate thumbprints since July 2023, so the
tls_certificatelookup you'll still see in many examples is optional.
Corrected: claims that didn't hold up when tested
I tested these on OpenTofu 1.10, which shares Terraform's HCL engine:
- "Read plan output bottom-up to see execution order." Not reliable. Plan output is sorted by resource address. Use
terraform graphfor ordering. - "Both branches of a conditional are evaluated, so the unused side can error."
var.on ? local.items[0] : "fallback"with an empty list andon = falsereturned"fallback"with no error. I left this out of the lessons.
Also worth knowing: a few code listings in the book have small typos (a misspelled reference here, a swapped argument there). Type the examples rather than pasting them, and run terraform validate.
The 20-point checklist
Score your main Terraform repo. Be honest.
State
- Remote backend with locking, encryption, versioning and access logging
- A state restore tested in the last quarter
- No human holds production credentials or production state on a laptop
- Scheduled plans to catch drift
- Secrets kept out of state where your version allows (ephemeral, write-only, or encryption)
Code
- Every provider declared with a source and constraint; lock file committed
- Every variable has a type, a description, and validation where it matters
- No
uuid()ortimestamp()in resource arguments for_eachfor distinct items,countfor toggles- No provisioners without a written reason
Modules
- No provider blocks in reusable modules
- Wide ranges in reusable modules, exact pins in root modules
- Working
examples/that double as test fixtures movedblocks for every rename, never deleted
Pipeline
- Same Makefile targets locally and in CI
fmt,validate, TFLint, Checkov and Trivy on every PR- Speculative plan on every PR; applies only from
main - OIDC for cloud access, with a
subcondition on every trust policy - Automated dependency PRs for providers and modules
Culture
-target,-refresh=falseandterraform refreshtreated as incident tools, not workflow
18 to 20: you're in great shape. 12 to 17: pick the three cheapest gaps and close them this sprint. Under 12: start with state and OIDC, because that's where the expensive incidents live.
Questions people ask
Does sensitive = true encrypt Terraform state?
No. sensitive = true only redacts a value from CLI output and logs. The value is still written to the state file in plain text. To keep a secret out of state, use an ephemeral resource with a write-only argument (Terraform 1.11 and later), pass the runtime a reference to a secret manager instead of the value, or encrypt state client-side with OpenTofu 1.7 and later.
Do I still need DynamoDB for Terraform S3 state locking?
No. Since Terraform 1.10 the S3 backend can lock state natively with use_lockfile = true, which writes a lock object next to the state file. HashiCorp's S3 backend docs mark DynamoDB-based locking as deprecated and say it will be removed in a future minor version. If you run both during a migration, Terraform acquires the lock from both sources.
Should Terraform modules pin exact provider versions?
Only root modules should. A reusable module should accept the widest range that works, such as ~> 6.0, so consumers can combine several modules without version conflicts. The root module, the one you actually apply, pins exact module versions and commits .terraform.lock.hcl, so every upgrade arrives as a reviewed pull request you roll out one environment at a time.
The real lesson
The biggest takeaway from Terraform in Depth isn't any single feature. Good Terraform is mostly software engineering discipline applied to infrastructure: interfaces, versioning, testing, review, and treating state with the care you'd give a production database. The HCL syntax is the easy part.
If you write Terraform for a living, the book is worth reading cover to cover, ideally with this list of updates next to it. What's the Terraform lesson your team learned the hard way? I'll collect the best ones into a follow-up.
Sources
- Terraform in Depth, Robert Hafner (Manning, 2025)
- S3 backend: use_lockfile and DynamoDB locking deprecation (HashiCorp)
- Terraform 1.10 changelog: S3 native locking and ephemeral values (HashiCorp)
- Write-only arguments (HashiCorp)
- State and plan encryption (OpenTofu)
- Terraform CDK repository, archived December 10, 2025 (HashiCorp)
- DeleteSecret recovery window (AWS)
- GitHub Actions OIDC with AWS no longer requires certificate pinning (GitHub)
Cite this article
Kiril Urbonas (2026). 50 Terraform Lessons Most Teams Learn Too Late. DevOpsNess. https://www.devopsness.com/blog/terraform-best-practices
[50 Terraform Lessons Most Teams Learn Too Late](https://www.devopsness.com/blog/terraform-best-practices) (Kiril Urbonas, DevOpsNess, 2026-11-08)
More from Infrastructure
Explore more articles in this category
Terraform Plan Wants to Recreate Your Production Database? Read This First
The plan says -/+ next to your RDS instance and one attribute ends in "forces replacement". Why it happens, how to undo it without data loss, and the CI gate that stops it reaching apply.
How to Stop Anyone From Deleting Your Production Database
GitLab lost six hours of data to one command on the wrong host. Linear lost five hours to one TRUNCATE CASCADE in a reviewed migration. The six guardrails that make that kind of mistake hard to make and cheap to recover from.
Kubelet Won't Start After Upgrading to Kubernetes 1.35? It's cgroup v1
Since Kubernetes 1.35 the kubelet refuses to start on cgroup v1 nodes by default. How to check every node in one command, the safe migration, and the temporary override with an expiry date.
You might have missed
Evergreen posts worth revisiting.