Back to Blog
TerraformIaCAWSDevOps

Terraform Best Practices I Wish I Knew Earlier

V
Vadym
β€’July 28, 2026β€’4 min read
Focus
Terraform
Reading
4 min
Format
Practical guide
Terraform Best Practices I Wish I Knew Earlier

Terraform looks simple when you start. You write some HCL, run terraform apply, and infrastructure appears. Six months later, you have a monolithic state file, no modules, and everyone's afraid to touch anything. Here's how to avoid that.

Guide roadmap

Structure Your Code for Scale from Day One

The worst Terraform I've seen is a single directory with one giant main.tf. Everything in one state, no separation, no reusability.

The structure I recommend for most teams:

infrastructure/
β”œβ”€β”€ modules/
β”‚   β”œβ”€β”€ vpc/
β”‚   β”œβ”€β”€ eks/
β”‚   β”œβ”€β”€ rds/
β”‚   └── s3-bucket/
β”œβ”€β”€ environments/
β”‚   β”œβ”€β”€ staging/
β”‚   β”‚   β”œβ”€β”€ main.tf
β”‚   β”‚   β”œβ”€β”€ variables.tf
β”‚   β”‚   └── terraform.tfvars
β”‚   └── production/
β”‚       β”œβ”€β”€ main.tf
β”‚       β”œβ”€β”€ variables.tf
β”‚       └── terraform.tfvars
└── global/
    └── iam/

Modules are reusable building blocks. Environments consume them. Global resources (IAM roles, S3 buckets for state) live separately.

Remote State with Locking

Never use local state in a team environment. Use S3 with DynamoDB locking on AWS:

terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "production/eks/terraform.tfstate"
    region         = "eu-west-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

The encrypt = true is important. State files often contain sensitive data β€” database passwords, private keys, API tokens.

Pin Your Provider Versions

Provider updates can break your code. Always pin:

terraform {
  required_version = ">= 1.6.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

Update providers deliberately, not accidentally.

Write Outputs and Use Them

Output values are how modules communicate. Don't hard-code IDs across modules β€” use outputs:

# In vpc module
output "private_subnet_ids" {
  value       = aws_subnet.private[*].id
  description = "IDs of private subnets"
}

# In eks module
module "cluster" {
  source     = "../eks"
  subnet_ids = module.vpc.private_subnet_ids
}

This creates explicit dependencies and makes your code readable.

Separate State Per Environment, Per Component

One state file for everything is a disaster waiting to happen. A failed plan for one component shouldn't block changes to another.

I split by:

  • Environment (staging, production)
  • Component (networking, compute, database, monitoring)

This also limits blast radius. If something goes wrong with a Terraform operation, only one component is affected.

Use terraform plan Output in CI/CD

Every pull request should show the Terraform plan as a comment. Engineers shouldn't be applying infrastructure changes without a reviewed plan.

My typical GitHub Actions workflow:

  1. terraform fmt -check β€” formatting check
  2. terraform validate β€” syntax validation
  3. terraform plan β€” output the plan as PR comment
  4. Manual approval for production
  5. terraform apply on merge to main

Never auto-apply to production without human review.

Drift Detection

Infrastructure drift β€” when the real state diverges from your Terraform state β€” is a constant problem. Schedule a regular terraform plan run in CI/CD, even with no code changes. Alert if there's a diff.

# In CI/CD schedule job
terraform plan -detailed-exitcode
# Exit code 2 means changes detected (drift)

Avoid count for Resources That Change

Using count to create multiple resources creates an indexed list. Remove the first item and everything shifts. Use for_each with a map instead:

# Bad β€” order-dependent
resource "aws_subnet" "private" {
  count = length(var.private_cidr_blocks)
  cidr_block = var.private_cidr_blocks[count.index]
}

# Good β€” key-based
resource "aws_subnet" "private" {
  for_each   = toset(var.private_cidr_blocks)
  cidr_block = each.value
}

Tag Everything

Every resource should have tags for:

  • Environment (staging, production)
  • Team or Owner
  • ManagedBy = "terraform"
  • Project

Use a local variable for consistent tagging:

locals {
  common_tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Project     = var.project_name
  }
}

These small habits save hours when you're trying to identify what a resource does or who owns it.

Need help with your infrastructure?

Let's work together

Available for remote B2B projects worldwide.

Get in Touch