Introduction

After managing Terraform codebases across multiple organizations and cloud providers, I’ve compiled these battle-tested best practices. Whether you’re starting fresh or refactoring existing infrastructure, these patterns will help you build maintainable, scalable IaC.

Project Structure

Standard Module Layout

terrame.RfontEodveAruirDmlncdrdsprM-eeoaoetraEpstmtnvaof.rwpam/gdmoubeirdjrtannmekestg-ci/es/vtn//e/gr/sion##mmaaiinn..ttff,,vtaerriraabfloersm..ttff,vaorust,pubtasc.ktefn,d.vtefrsions.tf

File Naming Conventions

FilePurpose
main.tfPrimary resources and module calls
variables.tfInput variable declarations
outputs.tfOutput value declarations
versions.tfProvider and Terraform version constraints
locals.tfLocal value definitions
data.tfData source declarations
backend.tfBackend configuration

Module Design Patterns

1. Single Responsibility

Each module should do one thing well:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Good: Focused module
module "vpc" {
  source = "./modules/networking/vpc"
  
  cidr_block    = var.vpc_cidr
  az_count      = 3
  enable_nat    = true
}

# Avoid: Kitchen sink module
module "everything" {
  source = "./modules/full-stack"  # Too much in one module
  # ... 50 variables ...
}

2. Sensible Defaults with Override Capability

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# modules/eks/variables.tf
variable "node_instance_type" {
  description = "EC2 instance type for worker nodes"
  type        = string
  default     = "t3.medium"  # Sensible default
}

variable "node_disk_size" {
  description = "Disk size in GB for worker nodes"
  type        = number
  default     = 50
  
  validation {
    condition     = var.node_disk_size >= 20 && var.node_disk_size <= 1000
    error_message = "Disk size must be between 20 and 1000 GB."
  }
}

3. Consistent Output Patterns

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# modules/vpc/outputs.tf
output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}

# Always output enough for dependent modules
output "vpc_cidr_block" {
  description = "The CIDR block of the VPC"
  value       = aws_vpc.main.cidr_block
}

State Management

Remote Backend Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# backend.tf
terraform {
  backend "azurerm" {
    resource_group_name  = "terraform-state-rg"
    storage_account_name = "tfstate${var.environment}"
    container_name       = "tfstate"
    key                  = "infrastructure.tfstate"
    
    # Enable state locking
    use_oidc = true
  }
}

State Locking and Encryption

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# AWS S3 backend with DynamoDB locking
terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "prod/infrastructure.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
    
    # Use IAM role for access
    role_arn = "arn:aws:iam::123456789:role/TerraformStateAccess"
  }
}

State Isolation Strategy

#stSaetpenkdaseuar/tbtaweatodsprdpbperetrnerarkvaoevososi.gdt.dedtnti.et.s.agfntsfttt/sgf/sffet.stssattattptfataaeestettrteeeaetnevironmentandcomponent

Variable Management

Use Variable Validation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
variable "environment" {
  description = "Deployment environment"
  type        = string
  
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "instance_count" {
  description = "Number of instances to create"
  type        = number
  
  validation {
    condition     = var.instance_count > 0 && var.instance_count <= 100
    error_message = "Instance count must be between 1 and 100."
  }
}

Complex Variable Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
variable "node_pools" {
  description = "Configuration for Kubernetes node pools"
  type = list(object({
    name          = string
    node_count    = number
    vm_size       = string
    os_disk_size  = optional(number, 100)
    labels        = optional(map(string), {})
    taints        = optional(list(string), [])
  }))
  
  default = [
    {
      name       = "default"
      node_count = 3
      vm_size    = "Standard_D4s_v5"
    }
  ]
}

Locals for Derived Values

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
locals {
  # Naming convention
  resource_prefix = "${var.project}-${var.environment}"
  
  # Common tags
  common_tags = {
    Environment = var.environment
    Project     = var.project
    ManagedBy   = "Terraform"
    Owner       = var.team
  }
  
  # Derived configurations
  is_production = var.environment == "prod"
  replica_count = local.is_production ? 3 : 1
}

Security Best Practices

1. Never Hardcode Secrets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Bad: Hardcoded secret
resource "aws_db_instance" "bad" {
  password = "supersecretpassword"  # NEVER do this
}

# Good: Use data sources or variables
data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "prod/database/password"
}

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

2. Sensitive Output Handling

1
2
3
4
5
output "database_password" {
  description = "The database password"
  value       = random_password.db.result
  sensitive   = true  # Prevents display in logs
}

3. Provider Authentication

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Use environment variables or managed identities
provider "azurerm" {
  features {}
  
  # Uses AZURE_* environment variables
  # Or Azure Managed Identity when running in Azure
}

provider "aws" {
  region = var.aws_region
  
  # Uses AWS_* environment variables
  # Or IAM role when running in AWS
}

CI/CD Integration

GitHub Actions Workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
name: Terraform CI/CD

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.6.0
      
      - name: Terraform Format Check
        run: terraform fmt -check -recursive
      
      - name: Terraform Init
        run: terraform init
        working-directory: environments/prod
      
      - name: Terraform Validate
        run: terraform validate
        working-directory: environments/prod
      
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        working-directory: environments/prod
        continue-on-error: true
      
      - name: Comment Plan on PR
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          script: |
            const output = `#### Terraform Plan 📖
            
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\``;
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
      
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve tfplan
        working-directory: environments/prod

Pre-commit Hooks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.83.5
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_docs
        args:
          - --hook-config=--path-to-file=README.md
      - id: terraform_tflint
        args:
          - --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
      - id: terraform_trivy

Testing Infrastructure

Terratest Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// test/vpc_test.go
package test

import (
    "testing"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestVPCModule(t *testing.T) {
    t.Parallel()
    
    terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
        TerraformDir: "../modules/vpc",
        Vars: map[string]interface{}{
            "cidr_block": "10.0.0.0/16",
            "az_count":   2,
        },
    })
    
    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)
    
    vpcId := terraform.Output(t, terraformOptions, "vpc_id")
    assert.NotEmpty(t, vpcId)
    
    subnetIds := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
    assert.Equal(t, 2, len(subnetIds))
}

Quick Reference

Essential Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Format code
terraform fmt -recursive

# Validate configuration
terraform validate

# Plan with variable file
terraform plan -var-file=prod.tfvars -out=plan.tfplan

# Apply saved plan
terraform apply plan.tfplan

# Import existing resource
terraform import aws_vpc.main vpc-12345

# Show state
terraform state list
terraform state show aws_vpc.main

# Move state
terraform state mv aws_vpc.old aws_vpc.new

# Taint for recreation
terraform taint aws_instance.web

# Output in JSON
terraform output -json

Conclusion

Following these best practices will help you build maintainable, secure, and scalable infrastructure. Start with a solid foundation, and iterate as your needs evolve.


Questions about Terraform? Let’s discuss!