I remember the first time I tried to scale a cloud environment using a single 2,000-line main.tf file. It was a nightmare. One small change to a security group rule triggered a cascade of unintended updates across my entire production stack. That’s when I realized that mastering terraform module best practices isn’t just about ‘cleaning up code’—it’s about risk mitigation and operational sanity.

If you’re just starting out, you might find a infrastructure as code for beginners guide helpful, but once you move past basic resources, modules become your primary tool for abstraction. In my experience, the difference between a ‘helper script’ and a true ‘production-grade module’ comes down to how you handle inputs, outputs, and versioning.

1. Follow the Standard Module Structure

Don’t reinvent the wheel. Terraform has a community-accepted standard for module layout. I always stick to this because it makes it effortless for new developers to jump into a project.


module-name/
├── main.tf          # Primary logic and resource definitions
├── variables.tf     # Input definitions
├── outputs.tf       # Exported values for other modules
├── versions.tf      # Provider and Terraform version constraints
└── README.md        # Documentation of what the module does
Example of a professional Terraform module directory structure in VS Code
Example of a professional Terraform module directory structure in VS Code

2. Favor Composition Over Complexity

One of the biggest mistakes I see is the ‘God Module’—a single module that tries to deploy a VPC, an EKS cluster, and an RDS instance all at once. Instead, build small, single-purpose modules and compose them in your root module.

For example, instead of a network_and_compute module, create a vpc module and a virtual_machine module. This makes your code far easier to test and reuse across different environments.

3. Use Strict Version Constraints

Nothing kills a Friday afternoon like a provider update that breaks your entire deployment. In your versions.tf, always specify the minimum required version of Terraform and the providers.


terraform {
  required_version = "~> 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

4. Make Variables Explicit and Described

Avoid ‘mystery variables.’ Every variable in your variables.tf should have a type and a description. When I’m reviewing a PR, I shouldn’t have to hunt through the main.tf to understand what var.instance_size actually controls.

5. Keep Modules ‘Opinionated’ but Flexible

A good module provides sensible defaults but allows overrides. I use the optional() modifier in variable objects to keep the calling code clean while providing advanced users the knobs they need.


variable "storage_config" {
  type = object({
    size_gb = optional(number, 20)
    type    = optional(string, "gp3")
  })
  default = {}
}

6. Implement Strong Output Patterns

Modules should be ‘black boxes’ that export only what is necessary. Avoid exporting every single attribute of a resource. Instead, export the specific IDs or ARNs that the next module in the chain will actually use. This reduces coupling and makes advanced terraform refactoring techniques much simpler when you need to change internal resource names.

7. Avoid Hardcoding Values

If you see a CIDR block or an AMI ID hardcoded inside a module, it’s a red flag. Use variables or data sources. In my setups, I use data "aws_ami" to dynamically fetch the latest Amazon Linux 2 image rather than pasting a string that will be obsolete in three months.

8. Use a Private Module Registry or Git Tags

Never call a module using a local path for production environments. Use Git tags to version your modules. This allows you to test a new version of a module in ‘Dev’ while ‘Prod’ remains locked to a stable tag.


module "vpc"
  source = "git::https://github.com/org/terraform-aws-vpc.git?ref=v2.1.0"

9. Document with READMEs and Examples

A module without an examples/ folder is a module that won’t be used. I always include a examples/basic and examples/complete directory. This shows other developers exactly how to implement the module without them having to guess the variable requirements.

10. Validate Inputs Using Custom Validation Blocks

Don’t let a typo in a variable name cause a deployment failure 10 minutes into a terraform apply. Use validation blocks to catch errors during the plan phase.


variable "environment" {
  type        = string
  description = "Deployment environment (dev, staging, prod)"
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "The environment must be one of: dev, staging, prod."
  }
}

Common Mistakes to Avoid

Measuring Success: Is Your Module Actually ‘Good’?

I judge the quality of a module by three metrics: Time to First Resource (how fast can a new dev deploy it?), Change Blast Radius (does updating the module break unrelated things?), and DRY Ratio (how much duplicated code was removed from the root?).

Ready to clean up your infra? Start by picking your most bloated resource group and extracting it into a versioned module today.