After managing infrastructure-as-code across multiple organizations, I've settled on a set of Terraform patterns that consistently work well for me.

The Module Structure

Every Terraform project I set up follows this layout:

infrastructure/
  modules/
    networking/
    compute/
    database/
    monitoring/
  environments/
    dev/
      main.tf
      variables.tf
      terraform.tfvars
    staging/
      main.tf
      variables.tf
      terraform.tfvars
    production/
      main.tf
      variables.tf
      terraform.tfvars

Each environment directory composes the shared modules with environment-specific variables. This keeps the blast radius small, so a change to dev/ can't accidentally affect production.

Tagging Everything

This is non-negotiable. Every resource gets tagged:

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

When you get the AWS bill at the end of the month and the CEO asks "what's costing us $12,000?", tags will quickly get you that answer.

State Management

Remote state in S3 with DynamoDB locking.

terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "production/networking/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

I've seen teams lose hours of work because someone ran terraform apply while another engineer was making changes. The DynamoDB lock prevents this entirely.

One note if you're starting fresh today: recent versions of Terraform can lock state with a native S3 lockfile, so the separate DynamoDB table is no longer strictly required. Same idea, fewer moving parts. Plenty of teams still run DynamoDB, and that's fine too.

Pin Your Versions

Nothing ruins a Friday like an unpinned provider quietly upgrading itself and changing a resource out from under you. Pin the Terraform version and your providers so every teammate, and your CI, runs the same thing.

terraform {
  required_version = ">= 1.9"

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

Then commit the .terraform.lock.hcl file next to it. That lockfile pins the exact provider versions, so a fresh checkout builds the same thing you tested instead of whatever shipped this morning.

Data Sources Over Hardcoding

Instead of hardcoding VPC IDs or subnet IDs, use data sources to look them up:

data "aws_vpc" "main" {
  tags = {
    Name = "${var.project_name}-${var.environment}"
  }
}

data "aws_subnets" "private" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.main.id]
  }
  tags = {
    Tier = "private"
  }
}

This makes your Terraform portable across accounts and reduces the "magic string" problem.

Plan Before Apply, Always

This should go without saying, but enforce it in CI:

# In your CI pipeline
- name: Terraform Plan
  run: terraform plan -out=plan.tfplan

- name: Terraform Apply
  run: terraform apply plan.tfplan
  when: manual  # Require human approval

Never let terraform apply run without a preceding plan. And in production, always require manual approval.

While you're in CI, cheap checks catch expensive mistakes. Run terraform fmt -check and terraform validate on every pull request, then add a linter like TFLint and a security scanner like Trivy or Checkov. They flag the formatting, the obvious misconfigurations, and the wide-open security group before a human ever spends time reviewing it.

Wrapping Up

These are some of the boring habits that keep infrastructure reviewable, reproducible, and easier to recover when something goes sideways.

I hope a few of these save you the headaches they've saved me. Take the ones that fit your team, skip the ones that don't, and adjust as you grow. That is really how good infrastructure gets built, one sensible default at a time.