on
Build your first Terraform module from scratch: a practical, modern guide
Writing a Terraform module for the first time can feel like composing a song: a few clear chords (resources) and a steady rhythm (inputs/outputs) come together to make something repeatable and pleasing. This article walks through the essential structure, sensible defaults, validation, documentation, versioning, and a basic CI check you can adopt right away — with a tiny, concrete example along the way.
Why a module?
- Reuse: capture a recurring pattern (for example, “create an S3 bucket with logging and tags”) so teams don’t retype the same configuration.
- Safety: centralize defaults and validation so consumers avoid footguns.
- Discoverability: good docs and examples make modules consumable across teams.
HashiCorp recommends a standard module structure and certain patterns that let tooling (and humans) understand and reuse your work. (developer.hashicorp.com)
Module anatomy — the minimum files
- main.tf — the resources and any local values.
- variables.tf — input variables (types, descriptions, defaults, validation).
- outputs.tf — module outputs.
- versions.tf (or required_providers/terraform blocks) — lock provider and Terraform constraints.
- README.md — what the module does, inputs, outputs, and basic usage.
- examples/ — at least one example showing the module in a simple real-world usage.
- CHANGELOG.md / RELEASE.md — track changes between versions when you publish.
These files aren’t arbitrary: they’re the structure tools expect when indexing or publishing a module, and they make your module registry-ready. (developer.hashicorp.com)
A tiny, focused example — an S3 bucket module Imagine a module named terraform-aws-s3-basic that creates a bucket with optional server-side encryption and versioning. Keep the module focused — don’t try to support every S3 option at first.
variables.tf (trimmed)
variable "name" {
type = string
description = "Bucket name (must be globally unique)"
}
variable "environment" {
type = string
description = "Logical environment"
default = "dev"
validation {
condition = contains(["dev","staging","prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "versioning" {
type = bool
default = false
}
main.tf (trimmed)
resource "aws_s3_bucket" "this" {
bucket = var.name
versioning {
enabled = var.versioning
}
tags = {
Environment = var.environment
ManagedBy = "terraform-module"
}
}
outputs.tf (trimmed)
output "bucket_id" {
value = aws_s3_bucket.this.id
description = "The S3 bucket ID"
}
Why this example? It’s small, demonstrates variable validation, uses outputs, and shows a focused scope — all of which align with module best practices. HashiCorp and community guidance encourage keeping modules narrow and providing clear examples. (hashicorp.com)
Input validation — make errors early and helpful Terraform supports variable validation blocks (available since Terraform 0.13) so you can catch bad inputs during plan time and show helpful messages. Use them to enforce allowed values, patterns, or ranges and craft error messages that explain the fix. Validation reduces support burden and makes your module feel polished. (developer.hashicorp.com)
Documentation and examples — the user experience A README.md should explain:
- Purpose and intended scope.
- Required inputs and sensible defaults.
- Typical outputs.
- A full example (in examples/) demonstrating how to call the module.
Automate README maintenance with tools like terraform-docs, which can generate or inject tables into README.md based on your variables and outputs — useful when you change a variable and don’t want stale docs. Aim for one clear example that mirrors how most teams will use the module. (github.com)
Versioning and publishing If you plan to publish to the public Terraform Registry or a private registry (Terraform Cloud / Enterprise), use semantic versioning (semver) for module releases. Registries expect repository structure, semver tags, and a clear change log; semantic versions let consumers pin module versions and reason about breaking changes. (docs.hashicorp.com)
A sensible versioning rule of thumb:
- Patch (x.y.z): fixes, docs, non-breaking changes
- Minor (x.y+1.0): new features that are backward-compatible
- Major (x+1.0.0): breaking changes
Testing and CI — a basic pipeline You don’t need a full integration test farm to get meaningful quality checks. A lightweight CI pipeline can run:
- terraform fmt (formatting)
- terraform init + terraform validate (syntax and configuration checks)
- tflint (static linting for provider best practices)
- terraform-docs to regenerate README (optional; can be a check or an auto-commit)
There are GitHub Actions and community actions to help:
- hashicorp/setup-terraform or hashicorp/terraform-github-actions to run Terraform commands in a workflow.
- terraform-linters/setup-tflint and other TFLint actions to run linter checks.
- terraform-docs has a GitHub Action to inject generated docs into README. (github.com)
Example (conceptual) CI job snippet
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check
- run: terraform init -backend=false
- run: terraform validate
- uses: terraform-linters/setup-tflint@vX
- run: tflint
- uses: terraform-docs/gh-actions@vX
with:
output-file: README.md
output-mode: inject
Keep the pipeline non-destructive: only run terraform apply in guarded, authenticated workflows (deploy branches or via Terraform Cloud run triggers). The community frequently recommends separating “validation” jobs from “deploy” jobs. (oneuptime.com)
Module ergonomics — good defaults and composability
- Supply safe defaults where possible, but require values that must be unique or environment-specific (like bucket names).
- Favor maps and objects when a resource needs structured settings (it’s easier to extend without breaking consumers).
- Use locals to compute derived values and keep resource blocks clean.
- Emit useful outputs (IDs, ARNs) so callers don’t need to reach into module internals.
Rightsizing modules: aim for composable pieces rather than a monolithic “kitchen sink.” Split network, compute, and policy concerns into separate modules and let higher-level “composition” modules combine them. This reduces coupling and makes testing simpler. (hashicorp.com)
Release hygiene — changelogs, examples, and compatibility
- Each release should include a changelog that highlights breaking changes clearly.
- Keep example usage in sync with the module version; tag example commits or use version constraints when referencing submodules.
- Use CI to reject changes that would break documented examples or cause terraform-docs to produce different output than checked-in README.
A short note on security scanning Static security tools (tfsec, checkov, Trivy) complement tflint and are worth integrating later; they flag common misconfigurations like public S3 buckets or weak IAM policies. These are separate from functional tests and help reduce blast radius in production.
A closing chord — keep it human Modules succeed when they’re easy for a person to understand and safe for a team to adopt. That means readable code, a brief README with a copy-paste example, a clear variable contract, and a CI pipeline that flags issues early. The ecosystem provides pragmatic tooling — from terraform-docs for documentation to TFLint and Actions for CI — that make modules more reliable and less hand-holding for maintainers and consumers. (github.com)
If you’d like a compact starter repo layout or the exact CI YAML shown above adapted for a specific provider (AWS, Azure, GCP), the snippets in this article can be turned into files and committed as a working starting point without adding complexity.