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.