Overview and What You Will Learn
A Terraform module is simply a folder of .tf files that does one job well — like creating a VPC or an ECS service — and lets anyone on your team use it without ever opening the folder to see how it works inside.
Picture the problem this solves. Five engineers at five different teams each need an S3 bucket with versioning, encryption, and a public access block turned off. Without modules, you get five almost-identical resource blocks scattered across five repositories — and when a security audit finds that one of them forgot to disable public access, you have no way to know how many other copies have the same bug. With a module, there is exactly one place that logic lives. Fix it once, and every team that calls the module gets the fix the next time they run terraform init -upgrade.
By the end of this lab you will be able to:
- Structure a module the way the Terraform Registry and production teams expect
- Write input variables with proper types, defaults, descriptions, and validation rules
- Decide what to expose as outputs and what to keep hidden inside the module
- Call your module from a root configuration and chain outputs between modules
- Version your module with Git tags so consumers control their own upgrade timing
- Write a README that documents inputs and outputs the way the registry requires
Why This Matters in Production
At Razorpay, the platform team's terraform-aws-ecs-service module is called by more than 40 different microservice repositories. Before the module existed, every team wrote its own ECS task definition — some forgot to set CPU limits, some used the wrong log driver, a few had no health check at all. After the module shipped, every one of those 40 services inherited the same correct defaults instantly. When the platform team later added mandatory Container Insights logging for a compliance requirement, they shipped one version bump — v4.0.0 — and every team picked it up on their own schedule by changing one line in their module block.
This is the entire economic case for modules: the cost of writing one is paid once, but the cost of NOT writing one compounds every single time someone copies and slightly breaks the pattern.
Core Principles
Anatomy of a Module Call
+--------------------------------------------------------+| ROOT MODULE (your repo) || module "ecs_service" { || source = "git::https://github.com/org/tf-modules || .git//ecs-service?ref=v4.0.0" || service_name = "orders-api" <-- INPUT || cpu = 512 <-- INPUT || } |+--------------------------------------------------------+ | v+--------------------------------------------------------+| CHILD MODULE (modules/ecs-service/) || variables.tf -- declares service_name, cpu, etc. || main.tf -- the actual resource blocks || outputs.tf -- exposes service_arn, task_def_arn || versions.tf -- required_providers, required_version || README.md -- documents every input and output |+--------------------------------------------------------+ | v module.ecs_service.service_arn <-- OUTPUT used back in the root module, or by another moduleDetailed Step-by-Step Practical Lab
Step 1 — Create the Standard Module Directory Structure
Every production-grade module follows the same skeleton. This is not arbitrary — the Terraform Registry and most tooling (like terraform-docs) expect these exact filenames:
mkdir -p modules/s3-secure-bucketcd modules/s3-secure-bucket # Standard files every module should have, even a small onetouch main.tf # the actual resource blockstouch variables.tf # input variable declarationstouch outputs.tf # output value declarationstouch versions.tf # required_providers and required_versiontouch README.md # documentation — inputs, outputs, example usage mkdir examplesmkdir examples/basic # a working example showing how to call this moduletouch examples/basic/main.tfmodules/s3-secure-bucket/├── main.tf├── variables.tf├── outputs.tf├── versions.tf├── README.md└── examples/ └── basic/ └── main.tfStep 2 — Pin the Module's Provider Requirements
# versions.tf — every module should declare its own version requirements,# separate from whatever the root module declaresterraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.0, < 6.0" # a range, not a single version — modules should be flexible } }}INFORMATIONTip from a senior engineer: modules should use looser version constraints than root configurations. A root module pins `version = "5.31.0"` exactly. A module should say `>= 5.0, < 6.0` so it does not block consumers who are on a slightly different provider version than you tested with.
Step 3 — Write Input Variables with Real Validation
This is where most beginner modules fall short — they accept any value and let AWS reject it with a confusing error three steps later. A good module validates inputs itself, with a clear error message:
# variables.tf variable "bucket_name" { description = "Globally unique S3 bucket name. Must be lowercase, no underscores." type = string validation { condition = can(regex("^[a-z0-9.-]+$", var.bucket_name)) error_message = "bucket_name must be lowercase letters, numbers, dots, and hyphens only." }} variable "environment" { description = "Deployment environment — used for tagging and lifecycle rules." type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be one of: dev, staging, prod." }} variable "enable_versioning" { description = "Whether to enable S3 versioning. Recommended true for prod." type = bool default = true # safe default — most callers won't need to think about this} variable "tags" { description = "Additional tags to merge with the module's default tags." type = map(string) default = {} # empty map default — caller can add tags without being forced to}Step 4 — Write the Module's Resource Logic
# main.tf — the actual infrastructure this module creates locals { # combine the caller's tags with the module's own mandatory tags common_tags = merge( var.tags, { ManagedBy = "terraform" Module = "s3-secure-bucket" Environment = var.environment } )} resource "aws_s3_bucket" "this" { bucket = var.bucket_name tags = local.common_tags} resource "aws_s3_bucket_versioning" "this" { bucket = aws_s3_bucket.this.id # implicit dependency — no depends_on needed versioning_configuration { status = var.enable_versioning ? "Enabled" : "Suspended" }} resource "aws_s3_bucket_server_side_encryption_configuration" "this" { bucket = aws_s3_bucket.this.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" # encrypted by default — not optional, not configurable } }} resource "aws_s3_bucket_public_access_block" "this" { bucket = aws_s3_bucket.this.id block_public_acls = true # hardcoded, not a variable — this module never allows public buckets block_public_policy = true ignore_public_acls = true restrict_public_buckets = true}INFORMATIONSecurity note: notice the public access block has no variable controlling it. Some settings should NOT be configurable by the caller. If your company's policy is "no S3 bucket is ever public," bake that into the module as a hardcoded rule, not an optional flag someone could accidentally set to false.
Step 5 — Decide What to Output
Only expose what callers actually need. Internal implementation details — like a randomly generated suffix you use internally — should stay hidden:
# outputs.tf — what this module exposes to whoever calls it output "bucket_id" { description = "The name of the created S3 bucket." value = aws_s3_bucket.this.id} output "bucket_arn" { description = "The ARN of the created S3 bucket — use this in IAM policies." value = aws_s3_bucket.this.arn} output "bucket_domain_name" { description = "The bucket's regional domain name — useful for CloudFront origins." value = aws_s3_bucket.this.bucket_regional_domain_name} # NOT exposed: encryption configuration details, internal locals —# callers don't need them and exposing them just adds surface area to maintainStep 6 — Call the Module from a Root Configuration
# environments/prod/main.tf — calling the module you just wrote module "customer_uploads_bucket" { source = "../../modules/s3-secure-bucket" # local path during development bucket_name = "razorpay-customer-uploads-prod" environment = "prod" enable_versioning = true tags = { Team = "platform" Cost-Center = "infra-2024" }} # using the module's output elsewhere in the same configurationresource "aws_iam_policy" "uploads_read" { name = "uploads-read-policy" policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["s3:GetObject"] Resource = "${module.customer_uploads_bucket.bucket_arn}/*" # chaining module output }] })}Step 7 — Test Locally, Then Tag a Version
While developing, point source at a local relative path. Once the module works, push it to its own repository and tag a release:
# inside the module's own repositorygit add .git commit -m "Initial release of s3-secure-bucket module"git tag v1.0.0git push origin v1.0.0 # now consumers switch their source to the tagged version# in the consuming repository — switching from local path to a pinned git tagmodule "customer_uploads_bucket" { source = "git::https://github.com/razorpay-platform/tf-modules.git//s3-secure-bucket?ref=v1.0.0" bucket_name = "razorpay-customer-uploads-prod" environment = "prod"}Step 8 — Write a README the Registry-Standard Way
# S3 Secure Bucket Module Creates an S3 bucket with versioning, AES256 encryption, and public accesspermanently blocked. ## Usage ```hclmodule "uploads" { source = "git::https://github.com/org/tf-modules.git//s3-secure-bucket?ref=v1.0.0" bucket_name = "my-app-uploads-prod" environment = "prod"}Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| bucket_name | Globally unique S3 bucket name | string | n/a | yes |
| environment | dev, staging, or prod | string | n/a | yes |
| enable_versioning | Enable S3 versioning | bool | true | no |
| tags | Additional resource tags | map(string) | {} | no |
Outputs
| Name | Description |
|---|---|
| bucket_id | The created bucket's name |
| bucket_arn | The created bucket's ARN |
| bucket_domain_name | The bucket's regional domain name |
> Tip: tools like `terraform-docs` can auto-generate this entire Inputs/Outputs table from your `variables.tf` and `outputs.tf` files — run `terraform-docs markdown . > README.md` and never write these tables by hand again. ### Production Best Practices and Common Pitfalls * **Name resources `this` inside modules, not after the module's purpose.** Since the module name itself already says what it is (`module "customer_uploads_bucket"`), naming the resource inside it `aws_s3_bucket.this` instead of `aws_s3_bucket.customer_uploads` avoids awkward repetition like `module.customer_uploads_bucket.aws_s3_bucket.customer_uploads_bucket`. * **Hardcode your company's non-negotiable security rules — don't make them variables.** If public S3 buckets are never allowed at your company, that rule belongs in the module's `main.tf`, not behind a `var.allow_public` flag someone could flip. * **Keep modules focused on one thing.** A module called `networking` that also creates IAM roles and an RDS instance is doing too much. Smaller, focused modules (`vpc`, `iam-role`, `rds-postgres`) compose together far more cleanly than one giant module. * **Always include an `examples/` folder.** A working example is the fastest way for someone to understand how to call your module — faster than reading the README, and it doubles as a manual integration test you can `terraform plan` after any change. * **Never skip the validation block on string inputs that map to real-world constraints.** S3 bucket names, IAM role names, and tag keys all have AWS-imposed character limits and formats. Catching a bad value with a clear `validation` error message saves the caller a confusing AWS API error five resources later. ### Quick Reference and Troubleshooting Commands | File | Purpose || :--- | :--- || `main.tf` | The actual resource blocks the module creates || `variables.tf` | Every input the module accepts, with type and validation || `outputs.tf` | Every value the module exposes to its caller || `versions.tf` | The module's own provider and Terraform version requirements || `README.md` | Human-readable usage, inputs table, outputs table | | Error | Root Cause | Fix || :--- | :--- | :--- || `Unsupported argument` when calling a module | Passing a variable name the module never declared | Check the module's `variables.tf` for the exact name and spelling || `Required variable not set` | A variable with no default was not passed in the module call | Pass every variable that has no `default` in the module block || Validation error message printed at plan time | Your input failed the module's own `validation` block | Read the custom `error_message` — it tells you exactly what is wrong || Module changes not appearing after edit | Using a pinned `ref=` tag instead of local path during development | Switch `source` to a local relative path while iterating, then re-tag when done |