Terraform modules are the building blocks of your infrastructure as code, but their true power is unlocked when you treat them as first-class software packages, subject to the same rigor as any other code.
Let’s see a module in action, not in theory, but as it’s used to provision a standard, secure S3 bucket.
# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
acl = "private"
versioning {
enabled = true
}
tags = merge(
{
"ManagedBy" = "Terraform"
},
var.common_tags
)
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
variable "bucket_name" {
description = "The name of the S3 bucket."
type = string
}
variable "common_tags" {
description = "A map of tags to apply to the bucket."
type = map(string)
default = {}
}
# root/main.tf
module "my_secure_bucket" {
source = "./modules/s3-bucket" # Or a Git URL, or Terraform Registry
version = "1.2.0" # Crucial for stability!
bucket_name = "my-super-secure-app-data"
common_tags = {
"Environment" = "Production"
"Project" = "MyApp"
}
}
output "bucket_id" {
value = module.my_secure_bucket.aws_s3_bucket.this.id
}
This example demonstrates how a reusable s3-bucket module is consumed. The root module calls the module, passing in specific variables for bucket_name and common_tags. The module then enforces sensible defaults like acl = "private" and versioning.enabled = true, and critically, uses aws_s3_bucket_public_access_block to prevent accidental public exposure. The version argument in the module block is not just a suggestion; it’s your primary defense against breaking changes.
The fundamental problem modules solve is duplication and inconsistency. Without modules, you’d be copying and pasting identical blocks of infrastructure code across multiple environments or projects. This leads to drift, errors, and a maintenance nightmare. Modules encapsulate a logical piece of infrastructure (like a VPC, a database, or in our case, a secure S3 bucket) into a reusable, versionable unit. They abstract away the complexity, exposing only necessary inputs (variables) and outputs.
Internally, Terraform treats modules as self-contained Terraform configurations. When Terraform plans or applies, it downloads the module code, resolves its variables, and then executes the resources defined within it, just as if they were in the root configuration. The key difference is how they are referenced (source) and managed (version).
The levers you control are the variables you define and expose. A well-designed module has a clear, concise set of variables that cover the common customization points without exposing every single resource attribute. Think about what makes your S3 bucket configuration different across your applications. Is it the name? The tags? Lifecycle rules? Access policies? These become your variables. The module enforces the common, secure defaults.
When you version modules, you’re not just tagging code; you’re declaring an API for your infrastructure. A change to a variable’s type, a removal of an input, or a change in resource behavior that impacts users constitutes a breaking change. The Semantic Versioning (SemVer) scheme (MAJOR.MINOR.PATCH) is your best friend here. Incrementing the PATCH version means a bug fix that doesn’t break anything. Incrementing MINOR means a new feature or non-breaking change. Incrementing MAJOR means you will break existing users, and they’ll need to update their code intentionally. Always lock your module versions in your root configurations using the version argument. This ensures that when you terraform apply today, it behaves identically to how it would behave next week, or next year, preventing unexpected infrastructure changes due to upstream module updates.
For modules sourced from Git repositories, you can specify tags (e.g., ref=v1.2.0), branches (e.g., ref=main), or even specific commits (e.g., ref=a1b2c3d4e5f6). While using branches or commits offers flexibility, it sacrifices the stability that versioning provides. Always prefer Git tags that align with SemVer for production modules. The Terraform Registry is another excellent source, providing discoverability and a standardized way to consume modules, often with built-in version constraints.
The next logical step after mastering module structure and versioning is understanding how to test these modules thoroughly to ensure their stability and correctness before they ever get used in production.