Back to blog
TerraformIaCAWSDevOps

Terraform Best Practices for Production Infrastructure

4 min read

Terraform is the standard for infrastructure as code — but a badly organized Terraform codebase is worse than clicking through the AWS console. It gives you the illusion of automation while hiding landmines in state files and tangled dependencies.

Here are the patterns we use across every client engagement to keep Terraform manageable at scale.

Module Structure

The single biggest lever for Terraform maintainability is module design. A module should do one thing: provision a VPC, create an EKS cluster, set up a database. Not all three.

infrastructure/
├── modules/
│   ├── vpc/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── eks/
│   └── rds/
├── environments/
│   ├── production/
│   │   ├── main.tf
│   │   ├── terraform.tfvars
│   │   └── backend.tf
│   ├── staging/
│   └── development/

Each environment composes modules. The modules are reusable; the environments are specific. This structure means adding a new environment is a copy-paste of config files, not a rewrite of infrastructure logic.

Remote State with Locking

If your state file is local, stop reading and fix that first. Remote state with locking prevents two engineers from applying conflicting changes simultaneously.

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

The DynamoDB table provides locking. The S3 bucket stores state. Encryption is non-negotiable — your state file contains every secret, every ARN, every IP in your infrastructure.

State per environment: Use separate state files for each environment. A single state file for everything means a `terraform plan` in staging could accidentally show (or apply) production changes.

Environments: Workspaces vs. Directories

There are two schools: Terraform workspaces or separate directories per environment. We use separate directories.

Workspaces share the same configuration files, which means environment-specific differences require conditionals scattered through your code. Separate directories are slightly more verbose but dramatically clearer — you can read production/main.tf and know exactly what's in production without tracing conditionals.

# environments/production/main.tf
module "vpc" {
  source = "../../modules/vpc"
  cidr   = "10.0.0.0/16"
  azs    = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

module "eks" {
  source          = "../../modules/eks"
  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.private_subnet_ids
  node_count      = 5
  instance_type   = "m5.xlarge"
}

CI/CD Integration

Terraform changes should go through the same review process as application code. Our standard pipeline:

  1. **PR opened** → `terraform plan` runs automatically, output posted as a PR comment
  2. **PR approved and merged** → `terraform apply` runs with the saved plan
  3. **Apply fails** → alert fires, state is unchanged (plan and apply are atomic)
# .github/workflows/terraform.yml
name: Terraform
on:
  pull_request:
    paths: ['infrastructure/**']
  push:
    branches: [main]
    paths: ['infrastructure/**']

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
        working-directory: infrastructure/environments/production
      - run: terraform plan -out=plan.tfplan
        working-directory: infrastructure/environments/production
      - uses: actions/upload-artifact@v4
        with:
          name: plan
          path: infrastructure/environments/production/plan.tfplan

  apply:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
        working-directory: infrastructure/environments/production
      - run: terraform apply -auto-approve
        working-directory: infrastructure/environments/production

Security Essentials

Never store secrets in Terraform variables or state unencrypted. Use a secrets manager:

data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "production/db-password"
}

resource "aws_db_instance" "main" {
  password = data.aws_secretsmanager_secret_version.db_password.secret_string
  # ...
}

Enable state encryption at the backend level (S3 server-side encryption, GCS encryption).

Use `terraform plan` diff reviews religiously. A terraform apply without reviewing the plan is the IaC equivalent of deploying without testing.

Pin provider versions to avoid surprise breaking changes:

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

Naming and Tagging

Every resource gets a consistent naming scheme and a standard set of tags:

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

Tags make cost allocation, access auditing, and resource cleanup possible. The ManagedBy = terraform tag prevents anyone from accidentally modifying a Terraform-managed resource through the console.

The Payoff

Well-structured Terraform turns infrastructure provisioning from a multi-day project into a pull request. New environment? Copy a directory, adjust the variables, merge. Disaster recovery? Apply the same modules to a different region. Audit? Every change is in Git history.


Wrestling with Terraform at scale? Talk to us — we build Terraform module libraries and CI/CD pipelines that make infrastructure changes boring (in the best way).

Need help with your infrastructure?

We build production-grade DevOps pipelines, cloud architectures, and SRE practices.

Start a project