Terraform
Terraform Best Practices That Hold Up in Production
State layout, locking, module contracts, provider pinning, and plan hygiene — the Terraform habits that prevent Friday-night apply incidents.
2026-09-03 · 5 min read
Most Terraform incidents are not syntax bugs. They are ownership bugs: one state file covering too much, a module that leaks provider details, or a plan that was regenerated after review.
This is a production-oriented set of practices. It assumes you already know init / plan / apply.
1. Split state by blast radius, not by folder convenience
One root module and one state file for “the whole company” is convenient for the first month and expensive forever.
Use this split:
- Network / identity — VPCs, IAM org structure, DNS zones. Slow-changing, high blast radius.
- Platform — EKS/GKE/AKS, shared logging, ingress, cluster addons.
- Product environments — per team or per service, never mixed with network state.
Each root gets its own backend, lock, and apply pipeline. Cross-stack data should flow through explicit outputs (terraform_remote_state or a published interface), not by reaching into another team’s state.
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-tf-state"
key = "network/prod/terraform.tfstate"
region = "us-east-1"
}
}
module "app" {
source = "./modules/app"
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}
If you cannot name the owner of a state file in one sentence, split it.
2. Remote state + locking is not optional
Local state on a laptop is a coordination failure waiting to happen. Use a backend with:
- encryption at rest
- access scoped to the pipeline identity
- native locking (S3 + DynamoDB, GCS, Azure Blob with lease, Terraform Cloud)
Never disable locking “just this once” to unblock an apply. If a lock is stuck, inspect the lock metadata and the running pipeline. Breaking a lock while another apply is live is how you get a corrupted state.
3. Pin providers and modules
Unpinned versions mean last week’s plan is not this week’s plan.
terraform {
required_version = ">= 1.8.0, < 1.10.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.68.0"
}
}
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.13.0"
}
Pin modules to an exact version in production roots. Use a renovate/dependabot PR to bump — do not float.
4. Treat the plan file as the artefact
Generate the plan once. Review that file. Apply that file.
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
# policy checks + human review against tfplan.json
terraform apply tfplan
Regenerating the plan between review and apply is the most common silent incident in Terraform CI. The reviewed diff is no longer the applied diff.
Plans older than a few hours should be discarded. The live API has moved.
5. for_each over count for anything with identity
count indexes resources by position. Delete item 0 and Terraform wants to destroy/recreate everything after it.
# Fragile
resource "aws_iam_user" "bot" {
count = length(var.bots)
name = var.bots[count.index]
}
# Stable
resource "aws_iam_user" "bot" {
for_each = toset(var.bots)
name = each.key
}
Use count only for “zero or one of this entire resource,” not for lists of named things.
6. Keep modules narrow
A good module has a short input list and a stable output contract. A bad module is a kitchen sink that accepts 80 variables “for flexibility.”
Rules of thumb:
- If a variable is only used to pass a provider-specific quirk through, the module leaked its implementation.
- Outputs are a public API. Rename them with a major version bump.
- Do not put
providerblocks inside reusable modules.
7. Tag at the provider default, not on every resource
provider "aws" {
region = var.region
default_tags {
tags = {
env = var.env
managed_by = "terraform"
owner = var.owner
}
}
}
Then enforce the presence of env and owner in CI (tflint / OPA / Checkov). Tags you forget to copy onto the 40th resource are the tags finance cannot attribute.
Common pitfalls
- Workspaces as environments.
default/prodworkspaces sharing one backend look tidy and hide blast radius. Prefer separate roots and separate state keys. terraform apply -auto-approvein production without a reviewed plan file.- Importing by hand and never updating the module. The next apply recreates the drift you just imported.
- One IAM role that can write every state bucket. Compromise of CI then means compromise of every environment.
ignore_changesas a habit. It is a documented exception, not a default. Everylifecycle.ignore_changesneeds an owner and a reason.
Recommended baseline
- State files mapped to owners and blast radius
- Remote backend with locking
- Provider and module versions pinned
- Plan artefact applied, not regenerated
-
for_eachfor named collections - Default tags + CI check for required keys
For a deeper look at multi-cloud module contracts, see Terraform module design for multi-cloud portability. For for_each vs count specifically, see when to use for_each instead of count. If you are choosing a CLI, read Terraform vs OpenTofu and the OpenTofu migration checklist.
