Building Reusable Infrastructure with Terraform Modules

Terraform modules are a powerful way to create reusable, maintainable infrastructure as code. In this comprehensive guide, we’ll explore how to create effective Terraform modules, understand best practices, and look at real-world examples.

What are Terraform Modules?

A Terraform module is a collection of Terraform resources that are grouped together in a meaningful way. Think of them as reusable building blocks for your infrastructure. Modules can represent:

  • Complete application stacks
  • Individual components (like a load balancer setup)
  • Cross-cutting concerns (like IAM roles and security groups)

Module Structure Best Practices

A well-organized Terraform module typically follows this structure:

module-name/
├── README.md           # Documentation
├── main.tf            # Main resource definitions
├── variables.tf       # Input variables
├── outputs.tf         # Output values
├── versions.tf        # Required providers and versions
└── examples/          # Example implementations
    └── basic/
        ├── main.tf
        └── terraform.tfvars

Creating Your First Module

Let’s create a practical example: a module that sets up an AWS S3 bucket with standardized settings and optional encryption:

# main.tf
resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
  
  tags = merge(
    var.tags,
    {
      Name = var.bucket_name
      Environment = var.environment
    }
  )
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id
  
  versioning_configuration {
    status = var.enable_versioning ? "Enabled" : "Disabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  count = var.enable_encryption ? 1 : 0
  
  bucket = aws_s3_bucket.this.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

# variables.tf
variable "bucket_name" {
  description = "Name of the S3 bucket"
  type        = string
}

variable "environment" {
  description = "Environment name (e.g., prod, staging, dev)"
  type        = string
}

variable "enable_versioning" {
  description = "Enable versioning on the bucket"
  type        = bool
  default     = true
}

variable "enable_encryption" {
  description = "Enable server-side encryption"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Additional tags for the bucket"
  type        = map(string)
  default     = {}
}

# outputs.tf
output "bucket_id" {
  description = "The name of the bucket"
  value       = aws_s3_bucket.this.id
}

output "bucket_arn" {
  description = "The ARN of the bucket"
  value       = aws_s3_bucket.this.arn
}

Using the Module

Here’s how to use the module we just created:

module "storage" {
  source = "./modules/s3-bucket"

  bucket_name       = "my-application-storage"
  environment      = "production"
  enable_versioning = true
  enable_encryption = true
  
  tags = {
    Project     = "MyApp"
    CostCenter  = "12345"
  }
}

Advanced Module Patterns

1. Conditional Resource Creation

Sometimes you want to make entire resources optional within your module:

variable "create_lifecycle_rule" {
  description = "Whether to create a lifecycle rule"
  type        = bool
  default     = false
}

resource "aws_s3_bucket_lifecycle_configuration" "example" {
  count = var.create_lifecycle_rule ? 1 : 0
  
  bucket = aws_s3_bucket.this.id

  rule {
    id     = "cleanup-old-files"
    status = "Enabled"

    expiration {
      days = 90
    }
  }
}

2. Dynamic Blocks

Dynamic blocks allow you to create multiple similar nested blocks based on a variable:

variable "cors_rules" {
  description = "List of CORS rules"
  type = list(object({
    allowed_headers = list(string)
    allowed_methods = list(string)
    allowed_origins = list(string)
    max_age_seconds = number
  }))
  default = []
}

resource "aws_s3_bucket_cors_configuration" "example" {
  count = length(var.cors_rules) > 0 ? 1 : 0
  
  bucket = aws_s3_bucket.this.id

  dynamic "cors_rule" {
    for_each = var.cors_rules
    content {
      allowed_headers = cors_rule.value.allowed_headers
      allowed_methods = cors_rule.value.allowed_methods
      allowed_origins = cors_rule.value.allowed_origins
      max_age_seconds = cors_rule.value.max_age_seconds
    }
  }
}

Module Composition

Modules can be composed together to create larger infrastructure units:

module "web_app" {
  source = "./modules/web-app"

  # This module might internally use other modules
  depends_on = [
    module.networking,
    module.database
  ]
}

module "networking" {
  source = "./modules/networking"
}

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

Best Practices

  1. Version Your Modules: Use semantic versioning for your modules and tag releases in your version control system.

  2. Documentation: Always include:

    • Required and optional variables
    • Example usage
    • Expected outputs
    • Required providers and versions
  3. Input Validation: Use variable validation to ensure inputs meet your requirements:

variable "environment" {
  type        = string
  description = "Environment name"
  
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}
  1. Keep Modules Focused: Each module should do one thing well. Avoid creating monolithic modules that try to do everything.

  2. Use Data Sources: Leverage data sources to make modules more flexible and avoid hardcoding values:

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

locals {
  account_id = data.aws_caller_identity.current.account_id
  region     = data.aws_region.current.name
}

Testing Modules

Always include examples that double as test cases. Consider using tools like:

  • Terratest for integration testing
  • terraform-docs for documentation generation
  • tflint for static analysis
  • checkov for security and compliance testing

Conclusion

Terraform modules are a powerful way to create reusable, maintainable infrastructure code. By following these patterns and best practices, you can create modules that are:

  • Easy to understand and use
  • Flexible enough to handle various use cases
  • Maintainable over time
  • Well-tested and reliable

Remember that good modules evolve over time based on real usage patterns and feedback. Start simple and refactor as needed based on actual use cases.