Terraform Modules: Structure and Best Practices

Terraform modules are reusable units of infrastructure configuration. They allow teams to standardize how resources are created and prevent duplication across environments. This post covers module str

Introduction#

Terraform modules are reusable units of infrastructure configuration. They allow teams to standardize how resources are created and prevent duplication across environments. This post covers module structure, versioning, and patterns that scale in large organizations.

Module Structure#

1
2
3
4
5
6
7
modules/
└── eks-cluster/
    ├── main.tf        # resource definitions
    ├── variables.tf   # input variables
    ├── outputs.tf     # output values
    ├── versions.tf    # required providers and Terraform version
    └── README.md      # usage documentation
1
2
3
4
5
6
7
8
9
10
# versions.tf: pin provider versions
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# variables.tf: document every variable
variable "cluster_name" {
  type        = string
  description = "Name of the EKS cluster. Used as a prefix for all related resources."
  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,39}$", var.cluster_name))
    error_message = "Cluster name must be lowercase alphanumeric and hyphens, 3-40 chars."
  }
}

variable "node_groups" {
  type = map(object({
    instance_types = list(string)
    min_size       = number
    max_size       = number
    desired_size   = number
  }))
  description = "Map of node group name to configuration."
  default = {
    general = {
      instance_types = ["t3.medium"]
      min_size       = 1
      max_size       = 5
      desired_size   = 2
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
# outputs.tf: expose values callers need
output "cluster_endpoint" {
  description = "EKS cluster API server endpoint."
  value       = aws_eks_cluster.main.endpoint
}

output "cluster_certificate_authority" {
  description = "Base64 encoded certificate authority for the cluster."
  value       = aws_eks_cluster.main.certificate_authority[0].data
  sensitive   = true
}

Module Versioning#

Pin modules to specific versions to prevent unexpected changes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Using a module from a Git repository
module "eks" {
  source  = "git::https://github.com/org/terraform-modules.git//eks-cluster?ref=v2.3.1"

  cluster_name = "production"
  node_groups = {
    general = {
      instance_types = ["m5.large"]
      min_size       = 3
      max_size       = 20
      desired_size   = 5
    }
  }
}

# Using a module from Terraform Registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.0"    # pin to exact version

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

Passing Data Between Modules#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# root/main.tf
module "vpc" {
  source = "./modules/vpc"
  cidr   = "10.0.0.0/16"
  name   = var.environment
}

module "eks" {
  source = "./modules/eks-cluster"

  cluster_name       = "${var.environment}-cluster"
  vpc_id             = module.vpc.vpc_id
  subnet_ids         = module.vpc.private_subnet_ids
  # Explicit dependency — not needed if using output references,
  # but sometimes useful for non-obvious dependencies
  depends_on         = [module.vpc]
}

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

  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.database_subnet_ids
  allowed_sg_ids  = [module.eks.node_security_group_id]
}

For Each and Dynamic Modules#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Create multiple similar resources using for_each
variable "services" {
  type = map(object({
    port          = number
    health_path   = string
    min_instances = number
  }))
}

module "ecs_service" {
  for_each = var.services
  source   = "./modules/ecs-service"

  name          = each.key
  port          = each.value.port
  health_path   = each.value.health_path
  min_instances = each.value.min_instances
}

# Access outputs from for_each modules
output "service_urls" {
  value = { for k, v in module.ecs_service : k => v.url }
}

Testing Modules#

1
2
# Terratest: Go-based testing for Terraform modules
# test/eks_cluster_test.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package test

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

func TestEKSCluster(t *testing.T) {
    opts := &terraform.Options{
        TerraformDir: "../examples/complete",
        Vars: map[string]interface{}{
            "cluster_name": "test-cluster",
            "region":       "us-east-1",
        },
    }

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

    clusterEndpoint := terraform.Output(t, opts, "cluster_endpoint")
    assert.Contains(t, clusterEndpoint, "amazonaws.com")
}

Module Design Principles#

  • Single responsibility: a module should do one thing well. A module creating a VPC, EKS cluster, and RDS in one block is too broad.
  • Sensible defaults: variables should have defaults where appropriate so callers can be concise.
  • No hardcoded values: regions, account IDs, naming conventions should all be variables.
  • Semantic outputs: expose everything a caller might need; avoid requiring callers to use data sources to find values your module already has.
  • Idempotency: applying the same configuration twice should produce no changes.

Conclusion#

Modules encode your organization’s infrastructure standards. Version them in Git with tags and reference specific versions in root modules. Use for_each for creating multiple similar resources. Test modules with Terratest before publishing. The discipline of pinning module versions and keeping modules focused prevents the common problem of a module update breaking many environments simultaneously.

Contents