Terraform Best Practices: Building Maintainable Infrastructure as Code

David Childs

Master Terraform with proven best practices for writing, organizing, and maintaining infrastructure as code that scales with your organization.

After managing infrastructure for dozens of production environments, I've learned that the difference between Terraform that works and Terraform that scales lies in following established best practices. Here's what I've learned the hard way.

Project Structure: Start Right

A well-organized Terraform project is easier to maintain, test, and scale. Here's the structure I recommend:

terraform/
├── modules/
│   ├── networking/
│   ├── compute/
│   └── database/
├── environments/
│   ├── dev/
│   ├── staging/
│   └── production/
├── global/
└── scripts/

Each environment should be isolated with its own state file and variable definitions. This prevents accidental changes from cascading across environments.

State Management: Your Safety Net

Remote State Storage

Never commit state files to version control. Use remote backends:

terraform {
  backend "s3" {
    bucket = "company-terraform-state"
    key    = "env/production/terraform.tfstate"
    region = "us-east-1"
    encrypt = true
    dynamodb_table = "terraform-state-lock"
  }
}

State Locking

Always enable state locking to prevent concurrent modifications. DynamoDB for AWS or Cloud Storage for GCP provide reliable locking mechanisms.

Module Design Principles

Keep Modules Focused

Each module should have a single responsibility. A "networking" module shouldn't create compute instances.

Use Semantic Versioning

Tag your modules with semantic versions:

git tag -a "v1.0.0" -m "First stable release"

Input Validation

Add validation rules to catch errors early:

variable "instance_type" {
  type = string
  validation {
    condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
    error_message = "Instance type must be t3.micro, t3.small, or t3.medium."
  }
}

Resource Naming Conventions

Consistent naming prevents confusion and conflicts:

locals {
  name_prefix = "${var.environment}-${var.project}"
}

resource "aws_instance" "web" {
  tags = {
    Name = "${local.name_prefix}-web-${count.index + 1}"
    Environment = var.environment
    ManagedBy = "Terraform"
  }
}

Security Best Practices

Never Hardcode Secrets

Use environment variables or secret management services:

data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "rds-password"
}

Implement Least Privilege

Create specific IAM roles for Terraform with minimal required permissions.

Enable Encryption

Always encrypt sensitive resources:

resource "aws_s3_bucket_server_side_encryption_configuration" "example" {
  bucket = aws_s3_bucket.example.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

Testing Your Infrastructure

Validate Before Apply

terraform fmt -check
terraform validate
terraform plan -out=plan.tfplan

Use Terratest for Automated Testing

Write Go tests to validate your infrastructure:

func TestTerraformAwsExample(t *testing.T) {
    terraformOptions := &terraform.Options{
        TerraformDir: "../examples/aws",
    }
    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)
    // Add assertions here
}

Collaboration Guidelines

Use Workspaces Carefully

Workspaces are not a replacement for separate environments. Use them for temporary variations, not permanent environment separation.

Document Everything

Include README files with:

  • Purpose of the module/configuration
  • Required variables
  • Example usage
  • Any prerequisites

Code Review Process

  • Require PR reviews for all changes
  • Run terraform plan in CI/CD
  • Use tools like tflint and checkov for static analysis

Common Pitfalls to Avoid

  1. Avoid Count When Possible: Use for_each for better resource tracking
  2. Don't Use Local State: Always use remote state with locking
  3. Avoid Inline Blocks: Use dynamic blocks for repeated configurations
  4. Don't Ignore Deprecation Warnings: Address them promptly
  5. Avoid Hardcoded Values: Use variables and data sources

Advanced Patterns

Blue-Green Deployments

resource "aws_lb_target_group_attachment" "blue" {
  count            = var.enable_blue ? 1 : 0
  target_group_arn = aws_lb_target_group.main.arn
  target_id        = aws_instance.blue[0].id
}

Multi-Region Deployments

Use providers with aliases:

provider "aws" {
  alias  = "us_west"
  region = "us-west-2"
}

Monitoring and Maintenance

Track Drift

Regularly run terraform plan to detect drift:

terraform plan -detailed-exitcode

Version Pinning

Pin provider versions to prevent unexpected changes:

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

Conclusion

These best practices aren't just recommendations – they're lessons learned from managing production infrastructure. Start implementing them gradually, and you'll find your Terraform code becomes more maintainable, reliable, and scalable.

Remember: Infrastructure as Code is code. Treat it with the same rigor as your application code, and it will serve you well.

Share this article

DC

David Childs

Consulting Systems Engineer with over 10 years of experience building scalable infrastructure and helping organizations optimize their technology stack.

Related Articles