Back to Blog
TerraformIaCAWSDevOps

Terraform Best Practices I Wish I Knew Earlier

V
Vadym
โ€ขJuly 28, 2026โ€ข4 min read
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.

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