Infrastructure as Code (IaC) has become the cornerstone of modern DevOps practices. Terraform, with its declarative syntax and multi-cloud support, is the most popular IaC tool. Let’s explore best practices that will make your Terraform code maintainable, secure, and scalable.

Project Structure

A well-organized Terraform project is crucial for long-term maintainability. Here’s a recommended structure:

terraform/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   └── production/
├── modules/
│   ├── networking/
│   ├── compute/
│   └── database/
└── global/
    └── state-backend/

Remote State Management

Never store your Terraform state locally in production. Use remote backends for collaboration and state locking.

S3 Backend Configuration

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

    # Enable versioning for state recovery
    versioning     = true
  }
}

State Locking with DynamoDB

Create a DynamoDB table for state locking:

resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }

  tags = {
    Name        = "Terraform State Locks"
    Environment = "global"
  }
}

Writing Reusable Modules

Modules are the building blocks of scalable Terraform configurations.

Module Example: VPC

# modules/networking/vpc/main.tf
variable "environment" {
  description = "Environment name"
  type        = string
}

variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "List of availability zones"
  type        = list(string)
}

locals {
  common_tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(
    local.common_tags,
    {
      Name = "${var.environment}-vpc"
    }
  )
}

resource "aws_subnet" "private" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone = var.availability_zones[count.index]

  tags = merge(
    local.common_tags,
    {
      Name = "${var.environment}-private-${var.availability_zones[count.index]}"
      Type = "private"
    }
  )
}

output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

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

Using the Module

# environments/production/main.tf
module "vpc" {
  source = "../../modules/networking/vpc"

  environment        = "production"
  vpc_cidr          = "10.0.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

Security Best Practices

1. Never Hardcode Secrets

# ❌ Bad
resource "aws_db_instance" "main" {
  password = "SuperSecret123!"
}

# ✅ Good - Use AWS 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
}

2. Use Variables and .tfvars Files

# variables.tf
variable "db_password" {
  description = "Database password"
  type        = string
  sensitive   = true
}

# terraform.tfvars (add to .gitignore!)
db_password = "actual-secret-here"

3. Implement Least Privilege

resource "aws_iam_role_policy" "app" {
  name = "app-policy"
  role = aws_iam_role.app.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject"
        ]
        Resource = "${aws_s3_bucket.app.arn}/*"
      }
    ]
  })
}

Terraform Workflow Best Practices

1. Use Workspaces for Environments

terraform workspace new production
terraform workspace new staging
terraform workspace select production

2. Always Run Plan Before Apply

# Generate and save a plan
terraform plan -out=tfplan

# Review the plan
terraform show tfplan

# Apply the exact plan
terraform apply tfplan

3. Use Pre-commit Hooks

Install terraform-docs and tflint:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.83.0
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint
      - id: terraform_docs

4. Implement CI/CD

Example GitHub Actions workflow:

name: Terraform

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

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2

      - name: Terraform Format
        run: terraform fmt -check

      - name: Terraform Init
        run: terraform init

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan

Advanced Patterns

Dynamic Blocks

variable "ingress_rules" {
  type = list(object({
    port        = number
    cidr_blocks = list(string)
  }))
}

resource "aws_security_group" "main" {
  name = "dynamic-sg"

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

Conditional Resources

variable "create_database" {
  type    = bool
  default = true
}

resource "aws_db_instance" "main" {
  count = var.create_database ? 1 : 0

  # resource configuration
}

Conclusion

Following these Terraform best practices will help you:

  • Maintain: Well-structured, modular code is easier to update
  • Scale: Reusable modules enable rapid infrastructure growth
  • Secure: Proper secret management and least privilege principles
  • Collaborate: Remote state and locking enable team workflows
  • Audit: Version control and state history provide complete audit trails

Remember: Infrastructure as Code is not just about automation—it’s about bringing software engineering practices to infrastructure management. Treat your Terraform code with the same care you’d give to application code.

Start implementing these practices incrementally, and you’ll build a robust, maintainable infrastructure foundation for your organization.