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 provider blocks 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

  1. Workspaces as environments. default / prod workspaces sharing one backend look tidy and hide blast radius. Prefer separate roots and separate state keys.
  2. terraform apply -auto-approve in production without a reviewed plan file.
  3. Importing by hand and never updating the module. The next apply recreates the drift you just imported.
  4. One IAM role that can write every state bucket. Compromise of CI then means compromise of every environment.
  5. ignore_changes as a habit. It is a documented exception, not a default. Every lifecycle.ignore_changes needs 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_each for 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.

Platform evaluation

See OrchesTerra on your infrastructure

Request access to generate architecture from a repository, review the plan, and run governed reconciliation across AWS, Azure, GCP, and OCI.