Skip to main content

Writing Reusable Terraform Modules — Structure, Variables, and Outputs

Write your first Terraform module from scratch — the right directory structure, input variables, outputs, and the patterns platform teams use at scale.

26 Terms

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
Bash
+--------------------------------------------------------+
| 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 module

Detailed 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:

Bash
mkdir -p modules/s3-secure-bucket
cd modules/s3-secure-bucket
touch main.tf # the actual resource blocks
touch variables.tf # input variable declarations
touch outputs.tf # output value declarations
touch versions.tf # required_providers and required_version
touch README.md # documentation — inputs, outputs, example usage
mkdir examples
mkdir examples/basic # a working example showing how to call this module
touch examples/basic/main.tf
◈ DIAGRAM
modules/s3-secure-bucket/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
└── examples/
└── basic/
└── main.tf
Step 2 — Pin the Module's Provider Requirements
HCL
# versions.tf — every module should declare its own version requirements,
# separate from whatever the root module declares
terraform {
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
}
}
}
Note

Tip 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:

HCL
# 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
HCL
# 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
}
Note

Security 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:

HCL
# 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 maintain
Step 6 — Call the Module from a Root Configuration
HCL
# 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 configuration
resource "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:

Bash
# inside the module's own repository
git add .
git commit -m "Initial release of s3-secure-bucket module"
git tag v1.0.0
git push origin v1.0.0
# now consumers switch their source to the tagged version
HCL
# in the consuming repository — switching from local path to a pinned git tag
module "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
MARKDOWN
# S3 Secure Bucket Module
Creates an S3 bucket with versioning, AES256 encryption, and public access
permanently blocked.
## Usage
```hcl
module "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
Bash
> 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 |

Resources

Explore More in Terraform Modules and Code Organisation

All 3 Topics

Frequently Asked Questions

Is Writing Reusable Terraform Modules — Structure, Variables, and Outputs free to learn on DevOps Network?

Yes - this topic, like everything on DevOps Network, is 100% free with no paywall or sign-up gate.

What does the Writing Reusable Terraform Modules — Structure, Variables, and Outputs topic cover?

Write your first Terraform module from scratch — the right directory structure, input variables, outputs, and the patterns platform teams use at scale.