DevOpsil
Terraform
92%
Needs Review

Writing Reusable Terraform Modules: A Practical Guide

Nabeel HassanNabeel Hassan7 min read

The difference between Terraform code that scales and Terraform code that becomes a maintenance nightmare usually comes down to one thing: whether the team invested in proper modules from the start. This guide walks through the practical decisions — directory layout, input validation, output design, and versioning — that separate a real module from a copy-paste shortcut wrapped in a folder.

What Makes a Module Actually Reusable

A reusable module has three properties:

  1. A clean interface — callers only need to know inputs and outputs, not what is inside.
  2. Sensible defaults — it works for 80% of cases with minimal configuration.
  3. Escape hatches — the remaining 20% can override behavior without forking the module.

Most teams write "modules" that are really just extracted resource blocks. Those are fine for DRY code but break down when you try to use them across teams or environments because every assumption is baked in as a hardcoded value.

Module Directory Structure

modules/
  aws-eks-cluster/
    main.tf          # Resource definitions
    variables.tf     # Input declarations
    outputs.tf       # Output declarations
    versions.tf      # Provider and Terraform version constraints
    README.md        # Usage examples (generate with terraform-docs)
  aws-rds-postgres/
    main.tf
    variables.tf
    outputs.tf
    versions.tf
    README.md

Keep each module in its own directory with exactly these four files. Do not put multiple modules in one directory — it confuses terraform-docs and makes versioning impossible when you move to a registry.

Writing the variables.tf File Properly

This is where most modules go wrong. Variables without descriptions, without types, and without validation are a trap for every future caller.

# modules/aws-eks-cluster/variables.tf

variable "cluster_name" {
  description = "Name of the EKS cluster. Must be unique within the AWS account and region."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{3,40}$", var.cluster_name))
    error_message = "cluster_name must be 3-40 lowercase alphanumeric characters or hyphens."
  }
}

variable "kubernetes_version" {
  description = "Kubernetes version for the EKS cluster control plane."
  type        = string
  default     = "1.29"

  validation {
    condition     = contains(["1.28", "1.29", "1.30"], var.kubernetes_version)
    error_message = "kubernetes_version must be one of: 1.28, 1.29, 1.30."
  }
}

variable "node_groups" {
  description = "Map of node group configurations."
  type = map(object({
    instance_types = list(string)
    min_size       = number
    max_size       = number
    desired_size   = number
    disk_size_gb   = optional(number, 50)
    labels         = optional(map(string), {})
    taints = optional(list(object({
      key    = string
      value  = string
      effect = string
    })), [])
  }))
}

variable "tags" {
  description = "Tags to apply to all resources created by this module."
  type        = map(string)
  default     = {}
}

Three habits to build here:

  • Every variable has a description.
  • Complex types use object() not any.
  • Use optional() for fields that have sensible defaults within a nested object.

Building the Main Resource File

# modules/aws-eks-cluster/main.tf

locals {
  common_tags = merge(var.tags, {
    ManagedBy = "terraform"
    Module    = "aws-eks-cluster"
  })
}

resource "aws_eks_cluster" "this" {
  name     = var.cluster_name
  version  = var.kubernetes_version
  role_arn = aws_iam_role.cluster.arn

  vpc_config {
    subnet_ids              = var.subnet_ids
    endpoint_private_access = true
    endpoint_public_access  = var.enable_public_endpoint
    public_access_cidrs     = var.public_access_cidrs
  }

  tags = local.common_tags

  depends_on = [
    aws_iam_role_policy_attachment.cluster_policy
  ]
}

resource "aws_eks_node_group" "this" {
  for_each = var.node_groups

  cluster_name    = aws_eks_cluster.this.name
  node_group_name = each.key
  node_role_arn   = aws_iam_role.node.arn
  subnet_ids      = var.subnet_ids
  instance_types  = each.value.instance_types
  disk_size       = each.value.disk_size_gb

  scaling_config {
    min_size     = each.value.min_size
    max_size     = each.value.max_size
    desired_size = each.value.desired_size
  }

  dynamic "taint" {
    for_each = each.value.taints
    content {
      key    = taint.value.key
      value  = taint.value.value
      effect = taint.value.effect
    }
  }

  labels = each.value.labels
  tags   = local.common_tags
}

The for_each on node groups is the escape hatch principle in action — callers can define any number of node groups with different specs without touching the module code.

Outputs That Are Actually Useful

# modules/aws-eks-cluster/outputs.tf

output "cluster_name" {
  description = "Name of the EKS cluster."
  value       = aws_eks_cluster.this.name
}

output "cluster_endpoint" {
  description = "HTTPS endpoint for the EKS control plane API."
  value       = aws_eks_cluster.this.endpoint
}

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

output "cluster_iam_role_arn" {
  description = "ARN of the IAM role used by the EKS control plane."
  value       = aws_iam_role.cluster.arn
}

output "node_group_arns" {
  description = "Map of node group name to ARN."
  value       = { for k, v in aws_eks_node_group.this : k => v.arn }
}

Rule of thumb: output everything a caller might need to chain into another module. Cluster name and endpoint are obvious. IAM role ARNs and security group IDs are less obvious but just as necessary.

Calling the Module from an Environment

# environments/staging/main.tf

module "eks" {
  source  = "../../modules/aws-eks-cluster"
  # Or from a registry:
  # source  = "app.terraform.io/myorg/eks-cluster/aws"
  # version = "~> 2.1"

  cluster_name       = "myapp-staging"
  kubernetes_version = "1.29"
  subnet_ids         = module.vpc.private_subnet_ids

  node_groups = {
    general = {
      instance_types = ["t3.large"]
      min_size       = 2
      max_size       = 6
      desired_size   = 3
    }
    compute = {
      instance_types = ["c6i.2xlarge"]
      min_size       = 0
      max_size       = 10
      desired_size   = 0
      labels = {
        workload = "compute-intensive"
      }
      taints = [{
        key    = "workload"
        value  = "compute-intensive"
        effect = "NO_SCHEDULE"
      }]
    }
  }

  tags = {
    Environment = "staging"
    Team        = "platform"
  }
}

The caller only sees the interface. They do not care how IAM roles are structured internally or how the cluster security groups are wired. That is the module abstraction working correctly.

Versioning Strategy

StageSource ReferenceWhen to Use
Local developmentsource = "../../modules/eks"Building and testing the module
Shared but pre-registrysource = "git::ssh://[email protected]/org/modules.git//eks?ref=v1.2.0"Small teams, no Terraform Cloud
Registry (recommended)source = "app.terraform.io/org/eks/aws" version = "~> 1.2"Production multi-team usage
Public registrysource = "terraform-aws-modules/eks/aws" version = "~> 20.0"Commodity infrastructure

Use semantic versioning. Breaking input changes are major versions. New optional inputs are minor. Bug fixes are patches. Tag your Git refs accordingly.

Common Mistakes to Avoid

Hardcoding region or account IDs. Always use data sources:

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

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

Using count instead of for_each for named resources. If you use count and remove an element from the middle of the list, Terraform destroys and recreates every resource after it. for_each on a map or set uses stable keys.

Leaking provider configuration into modules. Modules should never contain provider blocks with configuration. Pass provider aliases in from the root module if you need multi-region deployments.

No versions.tf. Every module needs this:

# modules/aws-eks-cluster/versions.tf

terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0, < 6.0"
    }
  }
}

Without version constraints, a provider upgrade can silently break your module for anyone consuming it.

Testing Modules with Terratest

A module without tests is a module waiting to break silently:

// test/eks_cluster_test.go

func TestEKSCluster(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../examples/basic",
        Vars: map[string]interface{}{
            "cluster_name":       fmt.Sprintf("test-%s", random.UniqueId()),
            "kubernetes_version": "1.29",
        },
    }

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

    clusterName := terraform.Output(t, terraformOptions, "cluster_name")
    assert.Contains(t, clusterName, "test-")
}

Run these in CI against a dedicated test account. They are slow (EKS takes 12 minutes to provision) but they are the only thing that catches real provider breaking changes.

Summary

PracticeWhy It Matters
Typed variables with validationFail fast at plan time, not apply time
for_each over countStable resource addressing
Output everything downstream needsAvoid tight coupling between modules
versions.tf with constraintsPrevents silent breakage on provider upgrades
Semantic versioning + tagsSafe consumption across teams
Terratest integration testsCatches breaking changes before they reach production

Reusable Terraform modules are not about being clever — they are about building a stable interface that other engineers and other teams can rely on without reading your source code. Invest the hour upfront in proper types, validation, and outputs. You will get it back tenfold when the fourth team asks to use your module without filing a support ticket.

Share:

Was this article helpful?

Nabeel Hassan
Nabeel Hassan

DevOps Educator

I break down complex DevOps concepts into things you can actually understand and use on Monday morning. Whether you're switching careers or leveling up, I write the guides I wish I had when I started.

Related Articles

TerraformQuick RefBeginnerNeeds Review

Terraform CLI: Cheat Sheet

Terraform CLI cheat sheet with commands organized by workflow — init, plan, apply, destroy, state manipulation, imports, and workspace management.

Zara Blackwood·
3 min read

More in Terraform

View all →

Discussion