The documentation gap that bites us all
You inherit a Terraform module that looked clean three months ago. Now you're staring at fifteen input variables, wondering which ones are actually required, what they do, and why the previous engineer chose this architecture. You find a README with a single paragraph and a shrug emoji from 2021. This is the moment documenting Terraform modules becomes not a nice-to-have, but a survival skill. Documenting Terraform modules: best practices explained covers the practical approach we've learned across production deployments—keeping docs current, generating them automatically, and embedding design decisions where they matter.
Good module documentation reduces onboarding time, cuts support questions, and catches misconfigurations before they hit staging. Let's walk through how to build documentation that stays synchronised with your code.
Using terraform-docs for automatic input and output tables
The biggest win we've found is delegating table generation to terraform-docs. Rather than manually maintaining a list of variables and their descriptions, you declare them once in your HCL, and the tool extracts them into clean, readable tables.
Install terraform-docs:
brew install terraform-docs
# or on Linux
wget https://github.com/terraform-docs/terraform-docs/releases/download/v0.16.0/terraform-docs-v0.16.0-linux-amd64.tar.gz
tar xzf terraform-docs-v0.16.0-linux-amd64.tar.gz
sudo mv terraform-docs /usr/local/bin/Now annotate your variables with helpful descriptions:
variable "instance_count" {
description = "Number of EC2 instances to launch. Must be between 1 and 20."
type = number
default = 3
validation {
condition = var.instance_count >= 1 && var.instance_count <= 20
error_message = "instance_count must be between 1 and 20."
}
}
variable "environment_tags" {
description = "Map of environment-specific tags applied to all resources."
type = map(string)
default = {
"Environment" = "production"
"ManagedBy" = "terraform"
}
}
variable "enable_monitoring" {
description = "Enable CloudWatch monitoring and alarms for this cluster."
type = bool
default = true
}Do the same for outputs:
output "instance_ids" {
description = "List of EC2 instance IDs created by this module."
value = aws_instance.main[*].id
}
output "load_balancer_dns" {
description = "DNS name of the application load balancer."
value = aws_lb.main.dns_name
}Generate the documentation:
terraform-docs markdown table . > docs/MODULE.mdThis produces clean markdown tables that are always in sync with your actual variables and outputs. No stale docs, no manual updates. We typically run this as a pre-commit hook or in CI to catch drift early.
Example usage in README
Variables and outputs are only half the story. Developers need to see a working example before they commit to using your module. A good README example includes realistic variable values, explains the intent, and shows what they can expect to get back.
Create a README.md with a concrete usage block:
# AWS ECS Cluster Module
This module provisions a production-ready ECS cluster with auto-scaling, CloudWatch logging, and optional monitoring.
## Usage
```hcl
module "ecs_cluster" {
source = "git::https://github.com/ourcompany/terraform-modules//ecs-cluster?ref=v2.1.0"
cluster_name = "production-api"
instance_count = 5
instance_type = "t3.large"
environment_tags = {
"Environment" = "production"
"Team" = "platform"
"CostCenter" = "engineering"
}
enable_monitoring = true
log_retention_days = 30
# Networking
vpc_id = aws_vpc.main.id
private_subnet_ids = aws_subnet.private[*].id
# Security
allowed_ssh_cidrs = ["10.0.0.0/8"]
}
output "cluster_arn" {
value = module.ecs_cluster.cluster_arn
}
output "autoscaling_group_name" {
value = module.ecs_cluster.asg_name
}
```
## Inputs
## Outputs
## Advanced Configuration
### Custom security groups
By default, the module creates a security group allowing inbound traffic on port 443 and SSH from your VPC CIDR. To add additional rules:
```hcl
module "ecs_cluster" {
# ... other config ...
additional_security_group_ids = [aws_security_group.custom.id]
}
```This example shows:
- A real cluster name and realistic instance count
- How to reference other resources (VPCs, subnets) that the caller must provide
- Common variable patterns like environment tags and logging configuration
- How to consume the module's outputs in your own infrastructure
- An advanced section for power users who need customisation
The key is to think like someone who's never seen this module before. What questions will they ask? Which configuration options are optional, and what do the defaults give them? Put yourself in their shoes.
Explaining design decisions
Documentation is not just a reference—it's where you defend your choices. Why did you split this into three modules instead of one? Why is that parameter required when the caller might not have it? Why does the module force a particular networking topology?
Add a "Design" or "Architecture" section to your README:
# Architecture & Design Decisions
## Why we separate networking from compute
This module focuses solely on ECS cluster provisioning. Networking (VPC, subnets, security groups) is provided by the caller. This separation allows teams to:
- Reuse networking infrastructure across multiple clusters
- Apply centralised network policies without module updates
- Avoid conflicts when clusters need different CIDR ranges or DNS configurations
If your team prefers an all-in-one approach, we recommend the `ecs-all-in-one` module instead.
## Instance type constraints
We enforce `t3.*`, `m5.*`, or `m6.*` instances only. `t2.*` instances are excluded because their burstable performance can cause unpredictable latency during traffic spikes, which we've observed in production. If your workload genuinely needs `t2` compatibility, open an issue with a use case and we'll discuss exceptions.
## Monitoring is on by default
CloudWatch monitoring adds ~$5/month per cluster but catches outages 10 minutes faster on average. We enable it by default because the cost is negligible next to compute, and early visibility has saved us multiple incidents. You can disable it with `enable_monitoring = false`, but we don't recommend it for production clusters.
## Why no RDS integration
This module doesn't provision databases. We found teams using a single database across multiple clusters caused coupling and scaling bottlenecks. Instead, each cluster's applications should reference their own RDS instances. Separate the concerns: database provisioning lives in its own module.This kind of context transforms documentation from a checklist into a learning tool. Future engineers understand not just what the module does, but why it was built this way, and when they might deviate from it.
Keeping docs in the same PR as changes
We've all seen modules where the code changed three releases ago but the README still references features that were removed. The solution is simple: documentation updates are part of the same pull request as code changes. No exception.
Set up a pre-commit hook to remind developers to update docs:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: terraform-docs
name: terraform-docs
entry: bash -c 'terraform-docs markdown table . > docs/MODULE.md'
language: system
files: '\.tf$'
pass_filenames: false
- id: check-docs-updated
name: Check module documentation is updated
entry: bash -c 'git diff --cached docs/MODULE.md | grep -q "." || (echo "Module docs not updated. Run: terraform-docs markdown table . > docs/MODULE.md" && exit 1)'
language: system
always_run: true
stages: [commit-msg]In your pull request template, add a reminder:
## Checklist
- [ ] Variables or outputs added/changed? Update `README.md` with new descriptions
- [ ] Design changed? Update the "Design Decisions" section
- [ ] Ran `terraform-docs markdown table . > docs/MODULE.md` to regenerate tables
- [ ] Docs are part of this PR, not a follow-upWhen code and documentation live in the same commit, they drift together. When you review a module PR, you're reviewing both implementation and its explanation. Reviewers spot outdated examples immediately because they're reading them side-by-side with the code.
For larger modules, consider a separate docs/ folder with topic-specific guides:
docs/MODULE.md— auto-generated inputs and outputsdocs/ARCHITECTURE.md— design decisions and trade-offsdocs/EXAMPLES.md— real-world usage patternsdocs/MIGRATION.md— upgrade paths when the module changesdocs/TROUBLESHOOTING.md— common errors and fixes
Keep all of these in the same commit as related code changes.
Common mistakes
Using vague variable descriptions: "Name of the cluster" doesn't help. Write "Friendly name for this ECS cluster; used as a prefix for all resource names and CloudWatch log groups. Must be lowercase, alphanumeric, and between 1 and 32 characters."
Example usage that doesn't run: If your example references resources that don't exist, developers will hit errors immediately and lose trust. Run your examples in a test workspace or CI before committing.
Forgetting why the module exists: Every module solves a problem. Say what that problem is in the first paragraph. If you're not sure, the module might not need to exist.
Outdated design sections: As your module evolves, revisit the design section. If you removed a limitation or added a new constraint, update the docs. Don't let old rationales linger.
Skipping migration guides: When you bump a major version, explain what broke and how to fix it. This prevents upgrade paralysis.
Key takeaways
- Use
terraform-docsto generate input and output tables automatically; remove the temptation to let documentation drift - Include realistic, runnable examples in your README showing how to actually use the module
- Explain design decisions, constraints, and trade-offs so future maintainers understand the reasoning
- Treat documentation updates as part of the same PR as code changes; use pre-commit hooks and checklists to enforce it
- For complex modules, split documentation into multiple files: generated reference, architecture, examples, troubleshooting, and migration guides
- Write for someone unfamiliar with the module; ambiguity invites misuse






Comments
Loading comments…