DEV Community

DevOps Daily
DevOps Daily

Posted on • Originally published at devops-daily.com

Terraform Infrastructure as Code Best Practices

Terraform has become the industry standard for infrastructure as code (IaC), allowing teams to provision and manage cloud resources through declarative configuration files. However, as your infrastructure grows, maintaining Terraform code can become challenging without following proper practices.

In this guide, you'll learn practical, battle-tested best practices for organizing, writing, and managing Terraform code that scales with your infrastructure needs.

Use a Consistent Directory Structure

Organize your Terraform code with a logical directory structure to enhance maintainability:

terraform-project/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   └── production/
├── modules/
│   ├── networking/
│   ├── compute/
│   └── database/
└── .gitignore
Enter fullscreen mode Exit fullscreen mode

This structure separates your environments from your reusable modules, allowing for clear organization and consistent deployments across environments.

Split Resources into Logical Modules

Instead of defining all resources in a single file, organize them into logical modules:

# modules/networking/main.tf
resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr

  tags = {
    Name        = "${var.project}-vpc"
    Environment = var.environment
    Terraform   = "true"
  }
}

resource "aws_subnet" "public" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name        = "${var.project}-public-subnet-${count.index}"
    Environment = var.environment
    Terraform   = "true"
  }
}

# Additional networking resources...
Enter fullscreen mode Exit fullscreen mode

Each module should represent a logical component of your infrastructure and handle a specific concern. This approach makes your code more maintainable and reusable.

Use Variables for Configuration

Define variables for all configurable parameters to make your modules flexible:

# modules/database/variables.tf
variable "instance_class" {
  description = "The instance type of the RDS instance"
  type        = string
  default     = "db.t3.micro"
}

variable "allocated_storage" {
  description = "The allocated storage in gigabytes"
  type        = number
  default     = 20
}

variable "engine_version" {
  description = "The engine version to use"
  type        = string
  default     = "13.7"
}

variable "database_name" {
  description = "The name of the database to create"
  type        = string
}

variable "environment" {
  description = "The deployment environment (dev, staging, prod)"
  type        = string
}
Enter fullscreen mode Exit fullscreen mode

Always include a description, type, and (when appropriate) a default value for each variable. This documentation helps other team members understand the purpose and requirements of each parameter.

Implement Consistent Naming and Tagging Conventions

Consistent naming and tagging greatly improve resource management and organization:

# Create a standardized tagging function
locals {
  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    Owner       = var.team
    ManagedBy   = "Terraform"
  }
}

resource "aws_s3_bucket" "logs" {
  bucket = "${var.project_name}-${var.environment}-logs"

  tags = merge(local.common_tags, {
    Name        = "${var.project_name}-${var.environment}-logs"
    Description = "Bucket for application logs"
  })
}
Enter fullscreen mode Exit fullscreen mode

Define a standard naming pattern for each resource type and consistently apply it throughout your infrastructure. This makes resources easily identifiable and simplifies operations and troubleshooting.

Use Data Sources to Reference External Resources

Use data sources instead of hardcoding values when referencing existing resources:

# Instead of hardcoding AMI IDs
data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical's AWS account ID
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  # Other instance configuration...
}
Enter fullscreen mode Exit fullscreen mode

This makes your code more flexible and easier to maintain as external resources change over time.

Keep Your Backend Configuration Consistent

Store your Terraform state in a remote backend with proper locking to enable team collaboration:

# environments/dev/backend.tf
terraform {
  backend "s3" {
    bucket         = "company-terraform-states"
    key            = "dev/terraform.tfstate"
    region         = "us-west-2"
    encrypt        = true
    dynamodb_table = "terraform-state-locks"
  }
}
Enter fullscreen mode Exit fullscreen mode

Use a consistent pattern for state file paths across environments. For example:

  • dev/terraform.tfstate
  • staging/terraform.tfstate
  • production/terraform.tfstate

Version Your Providers and Modules

Always specify versions for providers and modules to ensure reproducible infrastructure:

terraform {
  required_version = ">= 1.0.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16.0"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 3.18.0"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

For modules, specify versions in the source attribute:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "3.14.0"

  # Module parameters...
}
Enter fullscreen mode Exit fullscreen mode

This prevents unexpected changes when new provider or module versions are released.

Use Loops and Conditionals for DRY Code

Use Terraform's for_each, count, and conditional expressions to avoid repetitive code:

# Create multiple similar resources
resource "aws_security_group_rule" "ingress" {
  for_each = {
    http  = { port = 80, cidr = ["0.0.0.0/0"] }
    https = { port = 443, cidr = ["0.0.0.0/0"] }
    ssh   = { port = 22, cidr = ["10.0.0.0/8"] }
  }

  type              = "ingress"
  security_group_id = aws_security_group.web.id

  from_port   = each.value.port
  to_port     = each.value.port
  protocol    = "tcp"
  cidr_blocks = each.value.cidr

  description = "Allow ${each.key} traffic"
}

# Conditional resource creation
resource "aws_route53_record" "www" {
  count = var.create_dns_record ? 1 : 0

  zone_id = var.zone_id
  name    = "www.${var.domain_name}"
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.cdn.domain_name
    zone_id                = aws_cloudfront_distribution.cdn.hosted_zone_id
    evaluate_target_health = false
  }
}
Enter fullscreen mode Exit fullscreen mode

This approach makes your code more concise and easier to maintain.

Implement Automated Testing

Add automated tests to validate your Terraform code before deploying:

# testing/main.tf
module "test_vpc" {
  source = "../../modules/networking"

  project     = "test"
  environment = "dev"
  vpc_cidr    = "10.0.0.0/16"

  # Other required variables...
}

# Output test results
output "validation" {
  value = {
    vpc_created = module.test_vpc.vpc_id != ""
    num_subnets = length(module.test_vpc.subnet_ids)
  }
}
Enter fullscreen mode Exit fullscreen mode

Use tools like Terratest, kitchen-terraform, or simple shell scripts to test your configurations. Automated testing helps catch issues early and builds confidence in your infrastructure changes.

Use Workspaces Wisely

Terraform workspaces can be helpful for managing small variations, but they're not a substitute for proper environment separation:

# Better approach for managing environments
# Each environment has its own directory and state file
$ cd environments/dev
$ terraform apply

# Less ideal approach using workspaces
# All environments share module code but have different state files
$ terraform workspace select dev
$ terraform apply
Enter fullscreen mode Exit fullscreen mode

For production infrastructure, prefer separate environment directories with their own state files over workspaces.

Secure Your Terraform Configuration

Always follow security best practices:

  1. Store sensitive values in secure variable sources:
# Use variables for sensitive values, DON'T hardcode them
variable "database_password" {
  description = "Password for database access"
  type        = string
  sensitive   = true
}

# Reference from environment variables or secure input
# TF_VAR_database_password="secure-password" terraform apply
Enter fullscreen mode Exit fullscreen mode
  1. Use IAM roles with least privilege principles for Terraform execution

  2. Implement appropriate security groups and network ACLs:

resource "aws_security_group" "database" {
  name        = "${var.project}-${var.environment}-db-sg"
  description = "Security group for database instances"
  vpc_id      = var.vpc_id

  # Only allow access from application servers on the database port
  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [var.app_security_group_id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Document Your Infrastructure

Add meaningful comments and documentation to your Terraform code:

# modules/database/README.md
# Database Module

This module provisions an RDS PostgreSQL database with appropriate security groups
and backup configurations.
Enter fullscreen mode Exit fullscreen mode

Usage

module "database" {
  source = "../modules/database"

  project      = "ecommerce"
  environment  = "production"
  instance_class = "db.r5.large"

  # See variables.tf for all available options
}
Enter fullscreen mode Exit fullscreen mode

Inputs

Name Description Type Default Required
instance_class The RDS instance type string "db.t3.micro" no
allocated_storage The allocated storage in GB number 20 no
... ... ... ... ...

Outputs

Name Description
db_instance_endpoint The connection endpoint for the database
db_instance_id The RDS instance ID

Good documentation helps team members understand how to use your modules and reduces the learning curve.

Implement a CI/CD Pipeline

Automate your Terraform workflow with CI/CD:

  1. Validate syntax and format on pull requests
  2. Run terraform plan to check for potential changes
  3. Apply changes automatically (after approval)

Example GitHub Actions workflow:

name: 'Terraform CI/CD'

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

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

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

      - name: Terraform Format
        id: fmt
        run: terraform fmt -check -recursive

      - name: Terraform Init
        id: init
        run: |
          cd environments/dev
          terraform init

      - name: Terraform Validate
        id: validate
        run: |
          cd environments/dev
          terraform validate -no-color

      - name: Terraform Plan
        id: plan
        if: github.event_name == 'pull_request'
        run: |
          cd environments/dev
          terraform plan -no-color
        continue-on-error: true

      # Add approval and apply steps for production environments
Enter fullscreen mode Exit fullscreen mode

This helps ensure that your Terraform changes are properly reviewed and tested before deployment.

Conclusion

Following these best practices will help you create Terraform code that is maintainable, scalable, and secure. Remember that infrastructure as code is not just about automating deployments, it's about creating infrastructure that can evolve with your needs while maintaining reliability and security.

Start by implementing these practices incrementally in your existing projects. Focus first on modularization, consistent naming, and proper state management, then gradually adopt the more advanced practices like automated testing and CI/CD integration.

Happy infrastructure building!

Top comments (2)

Collapse
 
dotallio profile image
Dotallio

Super practical guide! Moving to a strict modular structure made my Terraform projects so much easier to update across environments.

Collapse
 
devopsdaily profile image
DevOps Daily

Thank you!