Guide · 12 articles branch off this one
Terraform Modules Explained
A comprehensive guide to understanding, creating, and managing Terraform modules for scalable Infrastructure as Code.

Key takeaways
- Terraform modules are reusable units of configuration: input variables in, output values out, encapsulated resources in between. Treat the inputs and outputs as a versioned contract.
- Git tags like v3.0.0 are only unique within a single repository. Two modules pinned to identically named tags in the same group can collide in ref-keyed module caches, so pin carefully and keep version timelines distinct.
- A private module registry's version list is a synced projection of your git tags, not a database you own. Verify the registry's view after any change to the VCS connection, and treat tags as the source of truth.
- Move to a private registry once three or more teams consume the same module; mandate SemVer, gate releases behind CI, and document every module.
- Registry sources decouple consumers from the runner's git and TLS environment; git-sourced modules inherit every quirk of the machine that runs terraform init.
- Test modules in layers: static analysis (validate, fmt, TFLint, Checkov), native terraform test for plan-level checks, and Terratest for end-to-end validation of critical modules.
What Are Terraform Modules?
A Terraform module is a set of Terraform configuration files (.tf or .tf.json) within a single directory that function as a reusable building block. Even your simplest Terraform configuration, run from one directory, is technically a "root module."
Modules act as reusable building blocks. They encapsulate a collection of resources, data sources, variables, and outputs, treating them as a single logical unit. Think of them as programming functions: they take inputs, provision infrastructure, and produce outputs.
The Module Hierarchy
Terraform configurations follow a hierarchical module structure:
- Root Module: This module invokes another module. It provides input variable values to the child module and consumes its outputs. Your root Terraform configuration, the one you run
terraform applyon, is always a root module. - Child Module: This module is called or instantiated by a root module. It defines resources and exposes functionality through inputs and outputs.
When a parent module calls a child module, the child gets its own isolated scope for resources and variables. That isolation prevents naming collisions, and it means a change inside a child module won't accidentally touch resources in the parent or in other child modules. You get infrastructure that stays predictable.

Why Use Modules?
Modules buy you a lot:
Organization and Readability
Break large infrastructure into smaller logical units. The code gets easier to read and easier to follow, which matters more and more as your codebase grows.
Reusability and Efficiency
Write infrastructure code once and reuse it across projects, teams, or environments. You drop the duplicate code, save time, and ship faster. This is where the Don't Repeat Yourself (DRY) principle pays off: instead of copying and pasting big blocks of Terraform for similar components, you call one module several times with different inputs.
Consistency, Abstraction, and Versioning
Three more benefits tend to show up together once a team commits to modules. Consistency comes first: a shared module makes common components deploy the same way across the org, which enforces your best practices, naming conventions, and security policies and cuts down on drift and errors. Abstraction follows, because a module hides the messy provisioning details from higher-level configs. Consumers only need to know the inputs and the outputs, so there's less to hold in your head.
Versioning is what ties it together. You version and manage each module on its own, so teams can work on specific components without stepping on each other, and updates roll out one at a time instead of all at once.
When to Create Modules
Use modules when you:
- Repeat the same resources: If you copy/paste Terraform code for similar components (e.g., multiple VPCs, specific EC2 instances, or database clusters).
- Need to enforce standards: To ensure infrastructure components always deploy with specific configurations, tags, or security settings across projects or teams.
- Want to abstract complexity: When a complex set of resources forms a logical unit (e.g., an application stack including network, compute, and database).
- Are building a shared library: For organizations providing pre-approved, tested infrastructure building blocks to development teams.
Module Structure and Standards
A well-structured module is easier to understand, use, and maintain. Terraform is flexible about how you lay things out, but a few conventions are widely adopted.
Standard Directory Layout
A typical module includes these core files:
my-module/
├── main.tf # Primary resource definitions
├── variables.tf # Input variable declarations
├── outputs.tf # Output value declarations
├── versions.tf # Terraform and provider version constraints
├── README.md # Essential documentation
├── examples/ # Usage examples
└── LICENSE # Software license
File Purposes
- main.tf: Contains the primary resource definitions of the module. For complex modules, this can be split (e.g.,
network.tf,compute.tf). - variables.tf: Declares all input variables the module accepts. This is the module's API. Each variable should have a type, a clear description, and a default value if optional.
variable "instance_type" {
description = "The EC2 instance type"
type = string
default = "t3.micro"
}- outputs.tf: Defines output values the module exposes to its calling module.
output "instance_public_ip" {
description = "The public IP address of the EC2 instance"
value = aws_instance.example.public_ip
}- versions.tf: Specifies required Terraform versions and provider constraints, ensuring compatibility.
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}- README.md: The documentation users actually read. This file should explain the module's purpose, usage, inputs, outputs, and provide examples.
Naming Conventions
Consistent naming improves readability and maintainability:
- General: Use underscores (_) to delimit words (e.g.,
web_server_sg) - Files: Lowercase with underscores (e.g.,
networking.tf) - Resources: Singular nouns. If only one of its type, consider naming it
main(e.g.,aws_instance.main). Don't repeat the resource type in the name. - Variables: Descriptive, using underscores. Include units for numeric values (e.g.,
ram_size_gb). Use positive names for booleans (e.g.,enable_monitoringinstead ofdisable_monitoring).
Creating Your First Module
Here's how to create a simple S3 bucket module to understand the complete process.
Step 1: Create Your Module Directory
my-terraform-project/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
└── s3_bucket/
├── main.tf
├── variables.tf
└── outputs.tf
Step 2: Define Input Variables
In modules/s3_bucket/variables.tf:
variable "bucket_name" {
description = "The name of the S3 bucket."
type = string
}
variable "acl" {
description = "The ACL to apply to the bucket (e.g., 'private', 'public-read')"
type = string
default = "private"
}
variable "tags" {
description = "A map of tags to assign to the bucket."
type = map(string)
default = {}
}Step 3: Define Resources
In modules/s3_bucket/main.tf:
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
acl = var.acl
tags = var.tags
versioning {
enabled = true
}
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
}Step 4: Define Outputs
In modules/s3_bucket/outputs.tf:
output "bucket_id" {
description = "The ID of the S3 bucket."
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "The ARN of the S3 bucket."
value = aws_s3_bucket.this.arn
}
output "bucket_domain_name" {
description = "The domain name of the S3 bucket."
value = aws_s3_bucket.this.bucket_domain_name
}Step 5: Use Your Module in Root Configuration
In your root main.tf:
provider "aws" {
region = "us-east-1"
}
module "my_first_bucket" {
source = "./modules/s3_bucket"
bucket_name = "my-unique-application-bucket-12345"
acl = "private"
tags = {
Environment = "Development"
Project = "TerraformModuleDemo"
}
}
module "my_second_bucket" {
source = "./modules/s3_bucket"
bucket_name = "another-unique-app-bucket-67890"
acl = "public-read"
tags = {
Environment = "Staging"
Owner = "TeamA"
}
}
output "first_bucket_arn" {
description = "ARN of the first S3 bucket."
value = module.my_first_bucket.bucket_arn
}Step 6: Initialize and Apply
# Initialize Terraform
terraform init
# Plan the changes
terraform plan
# Apply the configuration
terraform applyModule Inputs and Outputs
A module is only reusable if it accepts inputs and produces outputs. For more on variable types, validation, and outputs in Terraform/OpenTofu, see our variables and outputs guide.
Inputs (Variables)
You parameterize inputs with variable blocks inside the module. Instead of hardcoding values like region, instance types, or CIDR blocks, define them as variables. When you call the module, you pass values for those variables, which is what makes one module work across different scenarios.
Best practices for inputs:
- Name: Descriptive (e.g.,
instance_count,disk_size_gb) - Type: Always declare (e.g.,
string,number,bool,list(string),map(object(...))) - Description: Mandatory for every variable
- Default Value: Provide sensible defaults for optional variables; omit for required ones
- Validation: Use validation blocks to enforce constraints
- Sensitive: Mark sensitive inputs (passwords, API keys) with
sensitive = true - Minimize: Only expose variables that genuinely need to change
Outputs
You define outputs with output blocks inside the module. They expose specific values about the resources you created. The calling (parent) module, other modules, or external systems can then read those outputs for further configuration or information.
Best practices for outputs:
- Name: Descriptive
- Description: Mandatory. Explains what data the output provides
- Sensitive: Mark sensitive outputs with
sensitive = true
Sourcing Modules
The source argument handles a lot of different locations. If you're new to writing modules, start with our getting started with Terraform modules guide before getting into the details below.
Local Paths
source = "./modules/my-module"
source = "../shared-modules/vpc"Ideal for modules within the same project or during development.
Terraform Registry
Public Registry:
source = "hashicorp/vpc/aws"This gets you a big library of community and official modules.
Private Registry:
source = "app.terraform.io/my-org/vpc/aws"Share and manage modules securely inside your organization. Platforms like Scalr offer private module registries that hook into version control and give you the governance features you need once the company adopts them at scale.
Version Control Systems
source = "github.com/my-org/terraform-modules//aws/vpc?ref=v1.2.3"The // separates the repository URL from a path within the repo, and ?ref= pins to a specific branch, tag, or commit.
Pinning to a tag looks airtight, but tag names are only unique within a single repository, and module caches don't always remember that. A team we worked with at Scalr hit an intermittent Error: missing required argument in one workspace, and during init the contents of their Redis module were showing up inside their CloudFront module, which didn't use Redis at all. Both modules lived in the same GitLab group and were pinned to tags with the same name:
source = "git::ssh://[email protected]/<org>/infrastructure/modules/cloudfront?ref=tags/v3.0.0"
source = "git::ssh://[email protected]/<org>/infrastructure/modules/elasticache-redis?ref=tags/v3.0.0"Their hypothesis, which turned out to be right: a module cache key collision. Same group path, same ref, and the cache served one module's files where the other's belonged. Refreshing the cache fixed it for a while; the lasting fix was bumping one module's version so the two pins no longer matched. The lesson holds in general: v3.0.0 means nothing globally. Every repository has its own version timeline, and any cache keyed on refs can hand you someone else's v3.0.0.
HTTP Archives
source = "https://example.com/modules/my-module-v1.0.zip"Object Storage
source = "s3::https://my-bucket.s3.us-east-1.amazonaws.com/modules/vpc-module.zip"
source = "gcs::storage.googleapis.com/bucket/key.zip"Quick Reference Table
| Source Type | Example Syntax | Versioning |
|---|---|---|
| Local Paths | ./modules/local-module |
Via parent repository's VCS |
| Public Terraform Registry | hashicorp/vpc/aws |
version argument (e.g., ~> 1.0) |
| Private Registry (Scalr, HCP) | app.terraform.io/org/module/provider |
version argument |
| GitHub | github.com/owner/repo//path?ref=v1.0.0 |
?ref= query (tag, branch, commit) |
| Generic Git | git::https://example.com/repo.git//path?ref=tag |
?ref= query |
| S3 Buckets | s3::s3-region.amazonaws.com/bucket/key.zip |
Via S3 object key/versioning |
Module Design Patterns and Abstraction
The Abstraction Principle
Figure out the right level of abstraction for your modules. A monolithic module with dozens of conditional parameters gets hard to maintain and hard to understand.
Anti-pattern (over-engineered):
module "network" {
source = "./modules/network"
enable_nat_gateway = var.environment == "prod" ? true : false
enable_vpn = var.enable_vpn != null ? var.enable_vpn : (var.environment == "prod" ? true : false)
subnet_count = var.subnet_count != null ? var.subnet_count : (var.environment == "prod" ? 6 : 2)
# 20 more conditional parameters...
}Better approach (opinionated modules):
module "production_network" {
source = "./modules/production-network"
region = var.region
cidr_block = var.cidr_block
# Production standards are baked in
}Three-Layer Architecture
The most sustainable architectures follow a three-layer pattern:
- Base Modules: Individual resources (VPC, security groups)
- System Modules: Logical groupings (complete network stack)
- Environment Modules: Full deployments (dev, staging, prod)
This hierarchy works because it lines up with how teams actually think about infrastructure.
Encapsulation and Composition
- Design for Encapsulation: Group logically related resources. Hide underlying implementation details while exposing a clean interface.
- Embrace Module Composition: Favor smaller, focused modules that can be combined, over monolithic ones.
- Dependency Injection: Pass VPC IDs as inputs rather than creating VPCs inside compute modules.
Meta-Arguments for Advanced Control
version: Constrains the module version, ensuring stability.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0.0"
# ...
}count: Creates N instances based on an integer.
resource "aws_instance" "example" {
count = var.enable_instances ? 3 : 0
instance_type = var.instance_type
# ...
}for_each: Creates instances based on a map or set.
variable "environments" {
type = map(object({
cidr_block = string
instance_type = string
}))
}
module "env_vpc" {
for_each = var.environments
source = "./modules/vpc"
name = "vpc-${each.key}"
cidr_block = each.value.cidr_block
}providers: Passes specific provider configurations, essential for multi-region setups.
provider "aws" {
alias = "secondary_region"
region = "us-west-2"
}
module "app_in_secondary_region" {
source = "./modules/app_instance"
providers = {
aws = aws.secondary_region
}
instance_name = "my-app-us-west-2"
}Creating Modules with Cookiecutter
Cookiecutter is a command-line tool that creates projects from templates. The TerraformInDepth/terraform-module-cookiecutter is a template built to generate Terraform modules with a full set of best practices baked in from the start.
Key Features
The cookiecutter template bundles a set of tools and configs:
| Feature Category | Tool(s) | Purpose |
|---|---|---|
| Security Scanning | Checkov, Trivy | Identify security vulnerabilities and misconfigurations |
| Quality Control | TFLint | Lint Terraform code for errors and best practices |
| Formatting & Validation | Terraform/OpenTofu fmt & validate | Ensure consistent code style and syntactic correctness |
| CI Integration | GitHub Actions Workflows | Automate checks on code changes |
| Git Hooks | Pre-Commit Framework | Run local checks before commits |
| Version Management | tenv | Manage Terraform and OpenTofu versions |
| Testing | Terratest, Terraform Testing Framework | Unit and integration tests |
Getting Started
Install Cookiecutter:
pip install cookiecutterGenerate your module from the template:
cookiecutter gh:TerraformInDepth/terraform-module-cookiecutterYou'll be prompted for details like module name, Terraform provider, GitHub owner, and version information. The generated module will include:
- Standard Terraform files (
main.tf,variables.tf,outputs.tf) - An
examples/directory with usage examples - A
tests/directory with Terratest and native test setups .github/workflows/for CI/CD automation.pre-commit-config.yamlfor pre-commit hooks- A
Makefilefor common tasks - Version management files
Running Built-in Tools
Once it's generated, the built-in tooling starts paying off right away:
CI Pipeline: Pushing to GitHub automatically triggers validation, linting, security scanning, and testing.
Manual Checks:
# Run TFLint
tflint --recursive
# Run Checkov
checkov -d .Pre-Commit Hooks:
pre-commit installThis automatically runs configured checks before each commit.
Testing Terraform Modules
Testing modules well takes a layered approach.
Static Analysis
The foundation of your testing pyramid:
- terraform validate: Check syntax and basic structure
- terraform fmt: Ensure consistent code formatting
- TFLint: Identify potential issues and best practices violations
- Checkov/Trivy: Security vulnerability scanning
terraform validate
terraform fmt --recursive
tflint --recursive
checkov -d .Native Testing with HCL
Terraform v1.6+ added a native test command that lets you write declarative tests directly in HCL:
Use cases:
- Module Unit Testing: Verify that module inputs correctly translate into expected resource attributes
- Module Integration Testing: Validate that the module provisions the correct set of resources
- Quick Feedback Loops: Fast validation during module development
Example: Verifying an S3 bucket module
# Does it always create a private bucket?
# Does it correctly apply tags based on inputs?
# The native test framework can answer these directlyModule tests have a dependency problem of their own: if the module you're testing pulls other modules from a private registry, the test runner needs credentials too. One of our community members set up a tofu test workflow by the book (a read-only service account, a token for it, the provider config enabled for module tests) and still got error looking up module versions: 401 Unauthorized on every run. It turned out to be a known product issue at the time, not a config mistake, but their question stands either way: why generate a token in one part of a platform just to paste it into another part of the same platform? When you plan module testing, leave time for registry authentication, because when it breaks it looks exactly like user error.
Integration Testing with Terratest
For trickier scenarios, frameworks like Terratest (Go-based) let you run end-to-end tests:
func TestProductionNetworkModule(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/production-network",
Vars: map[string]interface{}{
"region": "us-west-2",
"cidr_block": "10.0.0.0/16",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
privateSubnets := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
assert.Equal(t, 3, len(privateSubnets))
}When to use Terratest:
- End-to-end testing with real deployments
- Complex post-deployment validation
- Cross-tool orchestration (Terraform + Helm + Kubernetes)
- Advanced error handling and reporting
Recommended Testing Pyramid
- Static Analysis (Fast, Cheap): terraform validate, fmt, TFLint, Checkov
- Plan Testing (Medium): Terraform native tests validating plan outputs
- Integration Testing (Slow, Expensive): Terratest for critical modules
- Drift Detection (Ongoing): Production monitoring with platforms like Scalr
Private Module Registries
What is a Private Module Registry?
A Private Module Registry is an organization-owned, central repository for storing, sharing, and managing Terraform modules. It's the secure, internal source of truth for all your pre-vetted, organization-specific Terraform configurations. For a hands-on walkthrough, see explaining a Terraform private module registry.

Comparison:
| Feature | Public Registry | Private Registry |
|---|---|---|
| Audience | Global community | Internal organization |
| Content | Third-party providers, generic modules | Proprietary, hardened, internal modules |
| Governance | Community/HashiCorp vetting | Organization enforced security & compliance |
Core Benefits
1. Enhanced Security & Compliance
The "golden image" approach: platform teams vet a module for security and compliance once. After it's approved and published, application teams consume that pre-hardened infrastructure, so security standards are met by default. Proprietary intellectual property stays safely inside the corporate boundary.
2. Standardization and Consistency (The "Golden Path")
Configuration drift is what kills stability. When you make teams use the same module for core infrastructure like networking or IAM roles, you get consistency across every environment and project.
3. Improved Developer Experience and Discoverability
An "App Store" for infrastructure: the private module registry gives you one searchable catalog where engineers browse, filter, and find pre-vetted components. Auto-generated documentation makes the modules easier to understand.
4. Simplified Dependency Management
The registry uses Semantic Versioning (SemVer). Module consumers can use intelligent version constraints (e.g., version = "~> 1.2.0") to automatically pull in bug fixes without risking breaking changes.
5. Decoupling Consumer from Source
When you pull a module over Git, your code is tied to the repository location. With a registry, you reference a logical name (e.g., <account>.scalr.io/<namespace>/vpc/aws). The registry sits in between as an abstraction layer, so you can reorganize backend storage without breaking the configs that consume the module.
There's a reliability angle to this decoupling, too. Git-sourced modules inherit every quirk of the git binary and TLS trust store on whatever machine runs terraform init. A customer running self-hosted Kubernetes agents once watched registry modules download cleanly while every git-sourced module in the same runs failed on a certificate problem local to the agent image. Registry modules travel over a protocol the platform controls end to end; git:: sources depend on the runner's environment behaving.

Setting Up a Private Module Registry
Option 1: Managed Solution (Recommended)
- Scalr: A comprehensive IaC management platform with a fully featured private registry. It automatically syncs modules from your VCS when you tag releases with SemVer (e.g.,
v1.2.3). Includes policy enforcement, environment management, and collaboration features. Available on the free tier (free up to 50 runs a month) for Terraform and OpenTofu. - Terraform Cloud / HCP Terraform: Offers native VCS and workspace integration. Includes zero infrastructure management and role-based access control. Free tier available; Terraform only.
- GitLab: For organizations already using GitLab for VCS and CI/CD, provides a built-in, native Terraform Module Registry. Convenient for keeping source, pipelines, and registry in one platform.
Option 2: Self-Hosted (For Custom Needs)
Tools like Terrakube implement the Terraform Module Registry Protocol, but you have to set up and maintain the API endpoints and module storage yourself. You get the most control and a lot more operational overhead.
One constraint to check before you publish anything: the module registry protocol expects sources named terraform-<provider>-<name>, or "2 dashes and 3 words," as a customer with a monorepo of internally named modules put it after hitting the wall with their short internal module names. That convention was built for one-module-per-repo open-source publishing, and it runs straight into private monorepos that follow their own naming scheme. Settle your naming before your first publish, while renames are still cheap.
Using a Private Module
The syntax is clean, so private modules are easy to use:
Public Registry Example:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
# ...
}Private Registry Example (Scalr):
module "standard_vpc" {
source = "<account>.scalr.io/<ACCOUNT_NAME>/standard-vpc/aws"
version = "1.2.3"
# ...
}The source clearly points to your organization's hostname, and authentication is handled via terraform login or API tokens.
Best Practices for Private Module Registry Management
- Mandate SemVer: Enforce strict adherence to Semantic Versioning (MAJOR.MINOR.PATCH)
- CI/CD Integration: Every module change must undergo automated testing and static analysis before release
- Producer/Consumer Access Control: Define which teams publish modules vs. consume them
- Prioritize Documentation: Every module must have high-quality README.md documentation
One more practice the bullet lists never mention: watch the VCS-to-registry sync, because it's the part that fails in interesting ways. A customer publishing 26 modules from a single monorepo pushed updates to all of them one morning and nothing showed up in the registry. The runner kept insisting the new versions didn't exist, and the fix was re-syncing each module by hand. A monorepo turns one webhook event into a fan-out of N sync jobs, and when that fan-out hiccups, every module goes stale at once. A team that switched VCS providers had it worse: two module repos with hundreds of releases each, and after reconnecting, old versions showed as failed or vanished from the registry entirely. A forced re-sync recovered only some of them, and new releases stopped showing up automatically. Here's what both incidents teach you: a registry's version list is a synced projection of your git tags, not a database you own. Treat the tags as the source of truth, and audit the registry's view of them after any change to the VCS connection.

Popular Terraform Modules
Looking at the most popular modules tells you a lot about common practices and which solutions people trust.
AWS: The Reign of terraform-aws-modules
For Amazon Web Services, the terraform-aws-modules collection remains the undisputed champion:
- terraform-aws-modules/iam/aws: 235.9 million all-time downloads. Foundational for Identity and Access Management.
- terraform-aws-modules/vpc/aws: 126.0 million all-time downloads. The go-to for setting up network infrastructure.
- terraform-aws-modules/s3-bucket/aws: 117.8 million all-time downloads. Essential for storage.
- terraform-aws-modules/eks/aws: 96.3 million all-time downloads. For Kubernetes on AWS.
- terraform-aws-modules/lambda/aws: 78.9 million all-time downloads. Powering serverless applications.
Example: AWS VPC Module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-app-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
tags = {
Terraform = "true"
Environment = "dev"
}
}Azure: Azure Verified Modules (AVM)
Microsoft Azure is taking a curated approach with Azure Verified Modules (AVM), officially supported, high-quality modules:
- Azure/avm-res-compute-virtualmachine/azurerm: Key module for VMs
- Azure/avm-res-storage-storageaccount/azurerm: Fundamental storage needs
- Azure/avm-res-network-virtualnetwork/azurerm: Core for VNet provisioning
- Azure/avm-res-containerservice-managedcluster/azurerm: For Azure Kubernetes Service
Example: Azure Storage Account
module "storage_account" {
source = "Azure/avm-res-storage-storageaccount/azurerm"
version = "~> 0.6"
name = "myappstgacct${random_string.suffix.result}"
resource_group_name = "my-app-rg"
location = "eastus"
account_tier = "Standard"
account_replication_type = "LRS"
tags = {
environment = "production"
project = "myapp"
}
}GCP: terraform-google-modules and Opinionated Design
Google Cloud Platform users lean heavily on terraform-google-modules, often called "opinionated" because they bake in Google's recommended best practices:
- terraform-google-modules/project-factory/google: 48.5 million all-time downloads. Standard for setting up GCP projects.
- terraform-google-modules/network/google: 36.0 million all-time downloads. For VPCs and firewall rules.
- terraform-google-modules/kubernetes-engine/google: 39.3 million all-time downloads. Standard for GKE clusters.
Example: GCP Project Factory
module "my_project" {
source = "terraform-google-modules/project-factory/google"
version = "~> 18.0"
name = "my-gcp-project-123"
random_project_id = true
billing_account = "YOUR_BILLING_ACCOUNT_ID"
folder_id = "YOUR_FOLDER_ID"
activate_apis = [
"compute.googleapis.com",
"storage.googleapis.com",
"container.googleapis.com",
]
labels = {
environment = "staging"
owner = "data-platform-team"
}
}Why These Modules Dominate
- Trust and Official Support: Official or community-backed modules come with assurance regarding quality and maintenance.
- Active Maintenance: Strong GitHub activity and responsive maintainers build confidence.
- Good Documentation: Clear documentation and practical examples lower adoption barriers.
- Policy Enforcement: Tools like Scalr integrate with OPA for applying organizational guardrails to module usage.
Module Best Practices
Design Principles
Modularity and Focused Scope: Each module should manage a specific, well-defined function or abstract a single cloud service. Avoid "thin wrappers" around single resources.
Encapsulation: Package logical resource groupings and hide implementation details. Expose a clean interface through inputs and outputs.
Idempotency: Applying the same module configuration multiple times should yield the same state without unintended changes. Layer in vulnerability scanning on every PR and Dependabot integration to keep dependencies current.
Separation of Concerns:
- Root Modules: Direct
terraform applytargets. Contain provider configurations and orchestrate reusable modules. - Reusable Modules (Child Modules): Building blocks designed for import. Should not contain provider configurations.
Development Practices
Clear Input/Output Conventions
Inputs should have specific types, clear descriptions, sensible defaults, and validation:
variable "instance_type" {
type = string
description = "The EC2 instance type for the web server."
default = "t3.micro"
}
variable "environment_name" {
type = string
description = "The name of the environment (e.g., dev, staging, prod)."
# No default - caller must specify
}
variable "enable_detailed_monitoring" {
type = bool
description = "Enable detailed CloudWatch monitoring."
default = false
}Semantic Versioning: Use MAJOR.MINOR.PATCH versioning. Maintain a CHANGELOG.md. In consuming configurations, always pin module versions.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0.0" # Allows 5.0.x but not 5.1.0 or 6.0.0
# ...
}Comprehensive Documentation: Write detailed README.md files and provide working examples in an examples/ subdirectory. Tools like terraform-docs can automate documentation generation.
Avoid Hardcoding: Parameterize all configurable values using input variables. Never hardcode environment-specific values or regions.
Minimize Dependencies: While modules can call other modules, avoid deep dependency trees. Circular dependencies are particularly problematic, so design clear boundaries where dependencies flow in one direction.
Testing Practices
- Static Analysis: terraform validate, terraform fmt, TFLint, Checkov
- Native Testing: Use terraform test (v1.6+) for HCL-based tests
- Integration Testing: Frameworks like Terratest for extensive testing
- Avoid Custom Scripts: Custom scripts via local-exec should be a last resort
Common Pitfalls and Solutions
The God Module
Problem: Creating modules that do everything with dozens of conditional flags.
Solution: Break into smaller, purpose-specific modules that compose together.
# Instead of this monolith:
module "everything" {
source = "./modules/kitchen-sink"
create_vpc = true
create_rds = true
create_ecs = true
# ...
}
# Do this:
module "database" {
source = "./modules/database"
engine = var.database_engine
}
module "compute" {
source = "./modules/compute"
instance_type = var.instance_type
}Hardcoded Values
Problem: Hardcoding environment-specific values or regions in modules.
Solution: Always parameterize with variables.
# Wrong
resource "aws_s3_bucket" "data" {
bucket = "my-company-prod-data-bucket"
region = "us-east-1"
}
# Right
resource "aws_s3_bucket" "data" {
bucket = var.bucket_name
# Region comes from provider configuration
}Missing Path Context
Problem: Using relative file paths that break when modules are used from other locations.
Solution: Use path.module to reference files relative to the module directory.
# Breaks when used as a module
data "template_file" "config" {
template = file("templates/config.json")
}
# Works everywhere
data "template_file" "config" {
template = file("${path.module}/templates/config.json")
}Copy-Paste Drift
Problem: Local module directories copied across projects, leading to divergent instances.
Solution: Promote modules to a versioned registry (public/private) or versioned Git repository.
Implicit Provider Configurations
Problem: Modules using default provider configurations the caller never explicitly set.
Solution: Modules must not define providers. Root module configures and passes providers. Modules declare required_providers.
Workspace Mismanagement
Problem: Over-reliance on Terraform CLI workspaces with complex conditional logic within modules.
Solution: Prefer directory-based environment separation. Each environment has its own root module.
Circular Dependencies
Problem: Module A depends on Module B's output; Module B depends on Module A's output.
Solution: Redesign module boundaries. If necessary, use terraform_remote_state data sources for indirect information sharing.
Refactoring Without Breaking Production
Terraform 1.1+ introduced moved blocks for safe refactoring:
moved {
from = aws_instance.web_server
to = module.web_tier.aws_instance.server
}
moved {
from = aws_security_group.web_sg
to = module.web_tier.aws_security_group.main
}
module "web_tier" {
source = "./modules/web-tier"
instance_type = "t3.large"
}Scaling Module Usage
For an in-depth playbook on rolling modules out across many teams, see scaling Terraform modules and deep dive into Scalr's platform architecture for governance patterns.
The Evolution Stages
Organizations typically progress through predictable stages:
- Chaos: Everyone writes their own Terraform
- Sharing: Teams copy each other's code
- Modules: Shared modules emerge organically
- Platform: Central team provides governed modules
The Platform Team Model
Successful enterprises establish clear ownership:
- Platform Teams: Create core modules and establish standards
- Domain Teams: Own specific infrastructure areas
- Application Teams: Consume modules from the registry
Module Registry Structure at Scale
terraform-modules/
├── networking/
│ ├── vpc/
│ │ ├── v1.0.0/
│ │ ├── v2.0.0/
│ │ └── v2.1.0/
│ └── security-groups/
├── compute/
│ ├── ecs-cluster/
│ └── kubernetes/
└── data/
├── rds/
└── dynamodb/
Managing Modules at Enterprise Scale
Scalability Challenges:
- Discovery: How do teams find the right modules?
- Versioning: Managing versions across many teams and projects
- Governance: Ensuring modules meet security and compliance standards
- Consistency: Preventing drift in how modules are used
Decision Matrix by Organizational Stage:
| Decision Point | Small Teams | Growing Organizations | Enterprise Scale |
|---|---|---|---|
| Module Design | Simple, focused | Layered (base/system/env) | Domain-driven with clear contracts |
| Testing Strategy | Manual plan review | Automated tests + Terratest | Full pyramid: static → integration → drift |
| Versioning | Git tags | Semantic versioning | Automated compatibility testing |
| Registry | Git repositories | Private module registry | Registry with dependency tracking |
| Governance | Code reviews | CODEOWNERS + PR templates | Policy-as-code enforcement |
| Team Model | Shared ownership | Platform + consumers | Federated with domain expertise |
Using Scalr's Private Module Registry
Scalr's private module registry with namespaces provides enterprise-scale module management:
Module Registry Namespaces
Namespaces act as dedicated, top-level registries for modules inside your Scalr account. They add an organizational layer so you can group modules by team, project, or department.
Key Features:
- Centralized Namespace Creation: Namespaces are created at account scope, establishing a clear organizational layer
- Flexible Sharing: Instead of all-or-nothing visibility, choose to share namespaces with all environments or select environments
- Namespace Ownership: Optionally assign team ownership; only that team can manage the namespace
- Backward Compatibility: Existing module URLs remain fully functional; no disruption to current IaC workflows
Example Usage:
module "standard_vpc" {
source = "account.scalr.io/networking/standard-vpc/aws"
version = "1.2.3"
cidr_block = "10.0.0.0/16"
region = "us-east-1"
}Key Success Factors at Scale
The organizations that get this right tend to do a few unglamorous things well:
- Invest Early: Create modules before you "need" them
- Choose Boring Technology: Standard patterns over clever abstractions
- Use Dedicated Platforms: Systems like Scalr handle governance and discovery so teams focus on infrastructure
Where to Start
Modules are what take Terraform from one-off scripts to infrastructure other people can safely reuse. Everything above is in service of that: an input/output contract you can version, a layout other engineers can read, tests that catch breakage before it ships, and a registry that hands consumers a logical name instead of a brittle git path.
If you remember a handful of things from this guide, make it these:
- Modules enable reusability, consistency, and scalability across your infrastructure
- Well-structured modules with clear inputs, outputs, and documentation are essential
- Test modules at multiple levels: static analysis, native tests, and integration tests
- Private module registries centralize module management and enforce organizational standards
- Scalr's private module registry with namespaces provides enterprise-grade module governance
You don't need any of this on day one. Build local modules for the patterns you already repeat, lay out your repository so it can grow, and move to a private registry once a few teams are sharing the same module. When that sharing starts to hurt, a platform like Scalr is what gives you the discovery, versioning, and policy enforcement that keep a growing module library from sliding back into copy-paste chaos. Scalr's registry is on the free tier, so you can try that step without a budget conversation.
This blog has been verified for Terraform and OpenTofu.
Frequently asked questions
What is a Terraform module?
A Terraform module is a set of Terraform configuration files in a single directory that functions as a reusable building block. It encapsulates resources, data sources, variables, and outputs as one logical unit. Like a function, it takes inputs, provisions infrastructure, and produces outputs. Even a simple configuration run from one directory is a root module.
When should I create a Terraform module?
Create a module when you find yourself copying and pasting Terraform code for similar components, when you need to enforce standards like tags or security settings across teams, when a complex set of resources forms a logical unit, or when you are building a shared library of pre-approved infrastructure building blocks.
What is the difference between sourcing a module from git and from a registry?
Git sources couple the consumer to the repository location and inherit the runner's git and TLS environment, while registry sources reference a logical name, support version constraints like ~> 1.2.0, and go through a protocol the platform controls. Registries also add search, access control, and auto-generated documentation.
What is a private module registry and when do I need one?
A private module registry is an organization-owned catalog for storing, versioning, and sharing internal Terraform modules. It becomes worthwhile once three or more teams consume the same module, because it adds SemVer-based version constraints, discoverability, and access control that ad hoc git sourcing lacks.
How should I version Terraform modules?
Use Semantic Versioning (MAJOR.MINOR.PATCH) with git tags, maintain a CHANGELOG, and always pin versions in consuming configurations. For example, version = "~> 5.0.0" to allow patch releases but block breaking changes. Remember that tag names are only unique per repository, not globally.
How do I test Terraform modules?
Follow a testing pyramid: fast static analysis with terraform validate, fmt, TFLint, and Checkov; plan-level tests with the native terraform test framework (v1.6+); and Terratest for end-to-end integration tests of critical modules. If module tests pull dependencies from a private registry, budget time for registry authentication as well.
About the author

director of platform engineering at Scalr
Ryan Fee is the director of platform engineering at Scalr, with over 15 years of experience improving infrastructure experiences at companies large and small.
In this guide
12 articles- Terraform Registry: Public vs Private Module Registries Explained
- New Feature: Private Module Registry Namespaces
- Top 10 Most Popular Terraform Modules [2026]
- Dependabot for Terraform Modules: The Complete Setup Guide
- Use local execution mode for free with Scalr
- Scaling Terraform Modules
- Scalr offers a free private module registry
- Terraform Modules - Define, Enforce, Report
- Don't Build a Service Catalog
- Getting Started with Terraform Modules
- Terraform Credential Helper With Scalr
- New Feature: Deploy Terraform Modules From A Service Catalog