Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 014 / 100

Writing good Terraform modules: best practices explained

Modules others want to use.

Expona Admin11 min read

Introduction

Writing good Terraform modules is the difference between infrastructure code that scales across your organization and a maintenance nightmare nobody wants to touch. A well-designed module becomes a building block your team trusts, reuses, and improves over time—while a poorly structured one forces every consumer to work around its limitations.

In this guide, we'll cover the core practices for writing good Terraform modules: best practices explained, so you create modules that others actually want to use. Whether you're building internal platforms, standardizing deployments across teams, or open-sourcing your infrastructure patterns, these principles will help you ship modules that feel natural to consume.

Clear inputs with descriptions and types

The first thing a consumer sees is your module's input variables. If they're unclear, poorly typed, or lack documentation, adoption drops immediately. Every variable should tell users exactly what it does and what format it expects.

Start by defining precise types. Don't reach for any unless you have a genuine reason. Use specific types like string, number, list(string), or structured objects with object(). This catches mistakes at plan time rather than at 3 AM during a production incident.

Add descriptions to every single variable. Keep them concrete—explain what the input controls, any constraints, and what happens if it's omitted. Here's a real example from an RDS module we built for a fintech team:

hcl
variable "allocated_storage" {
  type        = number
  description = "Storage size in GB. Must be between 20 and 65536. Changing this value triggers a modification window."
  default     = 100

  validation {
    condition     = var.allocated_storage >= 20 && var.allocated_storage <= 65536
    error_message = "allocated_storage must be between 20 and 65536 GB."
  }
}

variable "db_name" {
  type        = string
  description = "Initial database name. Can only contain alphanumeric characters and underscores. Cannot be changed after creation."
  
  validation {
    condition     = can(regex("^[a-zA-Z0-9_]+$", var.db_name))
    error_message = "db_name must contain only alphanumeric characters and underscores."
  }
}

variable "backup_retention_days" {
  type        = number
  description = "Number of days to retain automated backups. Must be 1-35 for non-production, 1-90 for production."
  default     = 7
}

variable "enable_multi_az" {
  type        = bool
  description = "Deploy the database across multiple availability zones for high availability. Adds cost but eliminates single points of failure."
  default     = false
}

Notice the validation blocks. They provide immediate feedback to users instead of failing mysteriously downstream. The descriptions explain not just what the variable is, but why someone might use it and what trade-offs exist.

Organize variables logically. Group related inputs together. In complex modules, consider separating variables into files: variables.tf for core settings, variables_networking.tf for network configuration, and so on. This helps consumers understand the full surface area without overwhelming them.

Sensible defaults

Every optional variable should have a default that makes sense for the most common use case. This reduces cognitive load—users only override what they actually need to change.

The trick is striking the right balance. Set defaults that are secure, cost-effective for development environments, and align with industry best practices. A database module should enable encryption by default, not ask users to opt in. A security group should deny inbound traffic by default, not allow everything.

Here's an example from a Kubernetes cluster module we wrote for a travel SaaS platform:

hcl
variable "node_count" {
  type        = number
  description = "Number of worker nodes in the cluster."
  default     = 3
}

variable "node_instance_type" {
  type        = string
  description = "EC2 instance type for worker nodes."
  default     = "t3.medium"
}

variable "enable_cluster_autoscaling" {
  type        = bool
  description = "Enable Kubernetes Cluster Autoscaler for automatic node scaling."
  default     = true
}

variable "enable_pod_security_policy" {
  type        = bool
  description = "Enforce pod security policies to prevent privileged container deployments."
  default     = true
}

variable "log_retention_days" {
  type        = number
  description = "CloudWatch log retention for control plane logs."
  default     = 7
}

variable "tags" {
  type        = map(string)
  description = "Common tags to apply to all resources."
  default = {
    ManagedBy = "Terraform"
    CostCenter = "Engineering"
  }
}

The defaults here reflect production-safe choices. Three nodes is reasonable for most workloads. Pod security policies are on by default because it's the right security posture. These defaults let a new team member create a functional cluster with just a few lines of code.

However, don't default to permissive settings "because it's easier." That's a security debt that compounds. We've seen modules used for payment processing with logging disabled by default—a clear audit failure. Err on the side of safety and visibility.

Outputs for everything consumers need

Outputs are how your module communicates results back to the consumer. Missing critical outputs forces users to either dig into your module code (bad) or duplicate logic in their own (worse).

Think about what the consumer will actually need to do with your infrastructure. If you're building a database module, they need the endpoint, port, and database name to configure their application. If you're building a load balancer module, they need the DNS name to point their domain. Make these outputs easy to find and clearly named.

Here's an output structure from an API Gateway module for a ride-hailing platform:

hcl
output "api_endpoint" {
  value       = aws_apigatewayv2_api.main.api_endpoint
  description = "The full HTTPS endpoint for the API Gateway. Use this as your API base URL."
}

output "api_id" {
  value       = aws_apigatewayv2_api.main.id
  description = "The API Gateway API ID. Required for additional integrations or custom domain setup."
}

output "invoke_arn" {
  value       = aws_apigatewayv2_api.main.arn
  description = "The ARN of the API Gateway. Use this for IAM policies that need to reference the API."
}

output "stage_name" {
  value       = aws_apigatewayv2_stage.main.name
  description = "The deployment stage name (e.g., prod, staging). Append to the endpoint if using stage variables."
}

output "cloudwatch_log_group_name" {
  value       = aws_cloudwatch_log_group.api_logs.name
  description = "CloudWatch log group where API access logs are stored. Query here for debugging request issues."
}

output "certificate_arn" {
  value       = aws_acm_certificate.api.arn
  description = "ACM certificate ARN if using a custom domain. Store this if you need to renew or replace it."
}

Notice each output has a description. These descriptions tell the consumer what the output is for and when they'd actually need it. The invoke_arn explanation clarifies that it's specifically for IAM policies—a consumer reading this immediately knows if they need it.

Export computed maps or objects when appropriate. If your module creates multiple resources of the same type, let consumers iterate over them:

hcl
output "lambda_function_arns" {
  value = {
    for name, fn in aws_lambda_function.workers : name => fn.arn
  }
  description = "Map of worker function names to their ARNs. Use for step functions or event subscriptions."
}

output "security_group_ids" {
  value = {
    for name, sg in aws_security_group.tiers : name => sg.id
  }
  description = "Map of tier names to security group IDs. Reference by tier name in other modules."
}

This pattern makes it easy for consumers to reference specific resources without hardcoding names or managing lists manually.

README with an example

Your module lives or dies by its documentation. A thoughtful README is the difference between a module that's understood and one that's misused.

At minimum, include a complete working example. Show the simplest possible usage first, then gradually layer in complexity. We typically structure it as:

  1. Brief description of what the module does and when to use it
  2. Prerequisites (IAM permissions, VPC setup, etc.)
  3. Basic usage example
  4. All available variables with defaults
  5. All available outputs
  6. Real-world examples for common scenarios
  7. Troubleshooting section

Here's a real README excerpt from a cache module we built for an e-commerce platform:

hcl
# Redis Cache Module

Provisions an AWS ElastiCache Redis cluster with automatic failover, parameter group configuration, and optional subnet group for custom VPC placement.

## Basic Usage

```hcl
module "cache" {
  source = "./modules/elasticache_redis"
  
  cluster_id             = "my-cache"
  node_type              = "cache.t3.micro"
  num_cache_nodes        = 2
  automatic_failover     = true
}
```

## Production Example with Custom Configuration

```hcl
module "cache_prod" {
  source = "./modules/elasticache_redis"
  
  cluster_id             = "production-cache"
  engine_version         = "7.0"
  node_type              = "cache.r6g.xlarge"
  num_cache_nodes        = 3
  automatic_failover     = true
  
  parameter_group_id     = "custom-params"
  enable_encryption_at_rest = true
  enable_encryption_in_transit = true
  
  snapshot_retention_limit = 14
  backup_window            = "03:00-05:00"
  maintenance_window       = "sun:05:00-sun:07:00"
  
  security_group_ids       = [aws_security_group.cache.id]
  subnet_ids              = [aws_subnet.private_1.id, aws_subnet.private_2.id]
  
  tags = {
    Environment = "production"
    CostCenter  = "platform-engineering"
  }
}
```

## Accessing Your Cache

The module exports the following:
- `primary_endpoint_address`: Use this in your application connection string
- `reader_endpoint_address`: Use for read-only operations if multi-AZ is enabled
- `port`: Default 6379, override in variable `redis_port`

Connect from your application:

```
redis://your-app:your-password@primary-endpoint-address:6379/0
```

## Troubleshooting

Q: Connection timeout errors?
A: Verify the security group allows inbound traffic on port 6379 from your application's security group.

Q: Failover doesn't trigger?
A: Ensure `automatic_failover` is set to true. Multi-node clusters require at least 2 nodes.

This README tells users exactly how to use the module, shows them what's possible, and helps them debug common issues. It reduces support tickets significantly.

Common mistakes

No type constraints. Modules with untyped variables force consumers to guess the correct format and let invalid values slip through to resource creation. Always use specific types and validation.

Outputs that expose internal structure. Avoid exporting Terraform-specific metadata or internal IDs unless they're genuinely useful. Too many outputs confuse consumers about what they actually need.

Missing descriptions and examples. A variable named enable_feature_x with no description leaves consumers wondering what feature X actually is. Descriptions compound in value as the module grows.

Defaults that don't match reality. Setting a default database size to 5GB when most production workloads need 100GB means every team has to override it. Wrong defaults create friction and get overridden inconsistently.

Complex nested variables without structure. While Terraform supports deeply nested objects, simplify where possible. A module that requires users to construct elaborate map-of-list-of-object structures is a module nobody will use.

Forgetting locals for computed values. If you compute the same value multiple times (like a formatted name or tag set), use locals. It improves readability and prevents drift.

Key takeaways

  • Define every variable with a specific type, a clear description, and validation rules where constraints exist. Avoid any and encourage users to provide exactly what you need.
  • Set sensible defaults that reflect production best practices—secure by default, cost-appropriate for most use cases, and aligned with what teams actually deploy.
  • Export outputs for everything a consumer might need, with descriptions explaining when and why they'd use each one. Computed maps make it easy to reference multiple related resources.
  • Write a README with a working basic example, then layer in complexity. Include real-world scenarios, prerequisites, and troubleshooting guidance so consumers self-serve answers.
  • Test your module against the patterns you document. If the README example doesn't work as written, fix it or document the workaround.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles