Terraform Modules: Reusable Infrastructure Components

Terraform modules are the fundamental building blocks for creating reusable, composable infrastructure components. A module is simply a container for multiple resources that are used together,...

Key Insights

  • Terraform modules enable infrastructure reusability through encapsulation of related resources, reducing duplication and standardizing configurations across environments
  • Well-designed modules should have clear boundaries, sensible defaults, and flexible inputs—avoid creating modules for every single resource or over-abstracting simple infrastructure
  • Module versioning and explicit source references are critical for production stability; always pin module versions and test upgrades in non-production environments first

Introduction to Terraform Modules

Terraform modules are the fundamental building blocks for creating reusable, composable infrastructure components. A module is simply a container for multiple resources that are used together, packaged in a way that makes them easy to share and reuse across different projects and environments.

The DRY (Don’t Repeat Yourself) principle applies to infrastructure as much as application code. When you find yourself copying and pasting the same Terraform resource definitions across multiple projects or environments, you’ve identified a candidate for modularization. However, not everything deserves to be a module. A single S3 bucket used once doesn’t need abstraction, but a standardized S3 bucket configuration with encryption, versioning, lifecycle policies, and logging that you deploy dozens of times absolutely does.

Create modules when you have a logical grouping of resources that you’ll deploy multiple times, need to enforce organizational standards, or want to simplify complex infrastructure patterns. Skip modules for one-off resources or when the abstraction adds more complexity than it removes.

Module Anatomy and Structure

Every Terraform configuration is technically a module—what you typically write is called the “root module.” Child modules are separate modules that the root module calls. Understanding this distinction clarifies how Terraform processes configurations.

A well-structured module follows a consistent file organization:

s3-bucket/
├── main.tf          # Primary resource definitions
├── variables.tf     # Input variable declarations
├── outputs.tf       # Output value definitions
├── versions.tf      # Provider version constraints
└── README.md        # Documentation

The separation of concerns makes modules easier to understand and maintain. main.tf contains your resources, variables.tf defines the inputs that customize module behavior, and outputs.tf exposes values that calling modules might need. This structure isn’t mandatory, but it’s the community standard.

Creating Your First Module

Let’s build a practical S3 bucket module that implements security best practices by default. This module will handle encryption, versioning, and lifecycle management—common requirements that you don’t want to redefine every time.

modules/s3-bucket/main.tf:

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
  tags   = var.tags
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = var.versioning_enabled ? "Enabled" : "Disabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  bucket = aws_s3_bucket.this.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = var.kms_key_id != null ? "aws:kms" : "AES256"
      kms_master_key_id = var.kms_key_id
    }
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "this" {
  count  = length(var.lifecycle_rules) > 0 ? 1 : 0
  bucket = aws_s3_bucket.this.id

  dynamic "rule" {
    for_each = var.lifecycle_rules
    content {
      id     = rule.value.id
      status = rule.value.enabled ? "Enabled" : "Disabled"

      transition {
        days          = rule.value.transition_days
        storage_class = rule.value.storage_class
      }
    }
  }
}

modules/s3-bucket/variables.tf:

variable "bucket_name" {
  description = "Name of the S3 bucket"
  type        = string
}

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

variable "kms_key_id" {
  description = "KMS key ID for encryption (uses AES256 if not provided)"
  type        = string
  default     = null
}

variable "lifecycle_rules" {
  description = "List of lifecycle rules"
  type = list(object({
    id              = string
    enabled         = bool
    transition_days = number
    storage_class   = string
  }))
  default = []
}

variable "tags" {
  description = "Tags to apply to resources"
  type        = map(string)
  default     = {}
}

modules/s3-bucket/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
}

output "bucket_domain_name" {
  description = "The bucket domain name"
  value       = aws_s3_bucket.this.bucket_domain_name
}

This module encapsulates S3 best practices while remaining flexible through variables. Notice the sensible defaults: versioning is enabled by default, encryption is mandatory (choosing between KMS and AES256), and lifecycle rules are optional.

Using and Calling Modules

Consuming modules is straightforward with the module block. You can reference modules from local paths, Git repositories, or the Terraform Registry.

environments/production/main.tf:

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

  bucket_name        = "myapp-logs-prod"
  versioning_enabled = true
  kms_key_id         = aws_kms_key.logs.id

  lifecycle_rules = [
    {
      id              = "archive-old-logs"
      enabled         = true
      transition_days = 90
      storage_class   = "GLACIER"
    }
  ]

  tags = {
    Environment = "production"
    Purpose     = "application-logs"
    ManagedBy   = "terraform"
  }
}

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

  bucket_name        = "myapp-assets-prod"
  versioning_enabled = false

  tags = {
    Environment = "production"
    Purpose     = "static-assets"
  }
}

# Access module outputs
output "logs_bucket_arn" {
  value = module.application_logs.bucket_arn
}

For remote modules, specify the source with version constraints:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "production-vpc"
  cidr = "10.0.0.0/16"
}

Always pin module versions in production. The ~> constraint allows patch updates but prevents breaking changes.

Module Composition and Best Practices

Great modules balance flexibility with opinionated defaults. Use variable validation to catch errors early:

variable "environment" {
  description = "Environment name"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "retention_days" {
  description = "Log retention in days"
  type        = number

  validation {
    condition     = var.retention_days >= 1 && var.retention_days <= 365
    error_message = "Retention days must be between 1 and 365."
  }
}

Implement conditional resource creation for optional components:

resource "aws_s3_bucket_public_access_block" "this" {
  count  = var.enable_public_access_block ? 1 : 0
  bucket = aws_s3_bucket.this.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Compose larger modules from smaller ones when building complex infrastructure:

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

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

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

  module "compute" {
    source = "./modules/ecs-service"
    # ...
  }
}

Document your modules thoroughly. A good README includes purpose, usage examples, input descriptions, and output descriptions. Many teams auto-generate documentation using terraform-docs.

Testing and Maintaining Modules

Test modules before deploying to production. Terratest provides a Go-based testing framework:

package test

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

func TestS3BucketModule(t *testing.T) {
    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/s3-bucket",
        Vars: map[string]interface{}{
            "bucket_name": "test-bucket-12345",
            "versioning_enabled": true,
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    bucketID := terraform.Output(t, terraformOptions, "bucket_id")
    assert.Equal(t, "test-bucket-12345", bucketID)
}

Use semantic versioning for modules: MAJOR.MINOR.PATCH. Increment MAJOR for breaking changes, MINOR for new features, and PATCH for bug fixes. Tag releases in Git:

git tag -a v1.2.0 -m "Add lifecycle rule support"
git push origin v1.2.0

Common Pitfalls and Troubleshooting

State Management: Each module maintains its own namespace in the state file, but they share the same state file when called from the same root module. Don’t try to import resources into modules directly—import to the root and refactor.

Circular Dependencies: Avoid modules that depend on each other’s outputs. This creates circular dependencies that Terraform cannot resolve. Restructure your modules to have clear hierarchies.

Over-Engineering: Don’t create modules for everything. A module that wraps a single resource with no added logic is worse than inline resources. Module overhead is justified when you gain reusability, standardization, or simplified complexity.

Upgrade Strategies: Never upgrade module versions directly in production. Test in development, validate in staging, then promote. Use terraform plan extensively:

# Review changes before applying
terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan

Output Dependencies: When chaining modules, ensure outputs are properly exposed. If module B needs a value from module A, module A must export it as an output.

Terraform modules transform infrastructure code from repetitive resource definitions into maintainable, reusable components. Start small with modules that solve real duplication problems in your infrastructure, establish versioning and testing practices early, and resist the urge to over-abstract. Well-designed modules become force multipliers for your infrastructure team, enabling rapid, consistent deployments across environments.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.