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?

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

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:

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:

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:

There are GitHub Actions and community actions to help:

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

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

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.