on
Writing your first Terraform module from scratch: structure, validation, and tests
Reusing infrastructure code with modules is the first step toward consistent, maintainable infrastructure-as-code. This short guide walks through a minimal, production-minded example: building a simple AWS S3 bucket module from scratch, adding input validation, and writing a native Terraform test so the module behaves as expected. Along the way you’ll see recommended files, small HCL examples, and pointers to tools that help keep modules high quality. HashiCorp’s official module guidance and the Terraform test framework are used as the canonical references. (developer.hashicorp.com)
Why modules (quick recap)
A Terraform module packages a set of resources and exposes a clean API (inputs and outputs). Modules promote reuse, reduce duplication, and make it easier to review and test infrastructure changes before they reach production. HashiCorp recommends modularizing shared logic and publishing modules internally or to the public registry for discovery and reuse. (developer.hashicorp.com)
Project layout (recommended minimum)
A tiny module should be easy to understand. Keep a clear file structure:
- README.md — purpose, inputs, outputs, example usage
- main.tf — the resources and provider blocks used by the module
- variables.tf — input variable declarations and validations
- outputs.tf — values exposed to callers
- versions.tf — minimal Terraform and provider version constraints
- tests/ — .tftest.hcl files for native Terraform tests (optional but recommended)
This layout mirrors HashiCorp’s examples and the community conventions for publishable modules. (developer.hashicorp.com)
Example: a tiny S3 bucket module
Below are minimal files to illustrate the pattern. Keep secrets out of modules — accept them as inputs or read from a secrets manager.
main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.0"
}
}
}
resource "aws_s3_bucket" "this" {
bucket = "${var.bucket_prefix}-${random_id.suffix.hex}"
acl = var.acl
tags = var.tags
}
resource "random_id" "suffix" {
byte_length = 4
}
variables.tf
variable "bucket_prefix" {
description = "Prefix used for the bucket name"
type = string
validation {
condition = length(var.bucket_prefix) > 2 && length(var.bucket_prefix) <= 30
error_message = "bucket_prefix must be 3..30 characters."
}
}
variable "acl" {
type = string
default = "private"
validation {
condition = contains(["private","public-read","log-delivery-write"], var.acl)
error_message = "acl must be one of private, public-read, log-delivery-write"
}
}
variable "tags" {
type = map(string)
default = {}
}
outputs.tf
output "bucket_name" {
description = "The created bucket name"
value = aws_s3_bucket.this.bucket
sensitive = false
}
versions.tf
terraform {
required_version = ">= 1.6.0"
}
Notes:
- Use a small deterministic suffix like random_id to avoid naming collisions in examples and to keep bucket names readable.
- Declare provider and Terraform version requirements in versions.tf to make compatibility explicit. Large community modules typically pin minimal Terraform and provider versions in this file. (deepwiki.com)
Input validation: friendly, early feedback
Terraform supports custom validation blocks inside variable declarations so a module can fail fast with a clear error if a caller passes an invalid value. Validation runs during plan/apply and produces the configured error_message when the condition is false. Use validation to check formats, allowed values, and length constraints — not to validate things the module computes later. (Some advanced cross-variable validation features were introduced in later Terraform versions; consult the language docs for exact behavior in your Terraform release.) (developer.hashicorp.com)
Writing tests with terraform test (native)
Terraform includes a testing framework that lets module authors write tests alongside the module using HCL test files (*.tftest.hcl). The framework is available in Terraform v1.6.0 and later and supports running plan or apply actions inside test “run” blocks, plus simple assertions to verify outcomes. Tests can either run in plan-only mode (fast, unit-like checks) or apply mode (integration-style, creates short-lived resources). (developer.hashicorp.com)
Example test that verifies the bucket name logic without creating long-lived infra (a plan run with an assertion):
tests/valid_string_concat.tftest.hcl
variables {
bucket_prefix = "test"
}
run "valid_string_concat" {
command = "plan"
assert {
condition = aws_s3_bucket.this.bucket == "test-bucket"
error_message = "S3 bucket name did not match expected"
}
}
Important test behavior to keep in mind:
- By default
runusescommand = apply, which will create resources; setcommand = planto avoid provisioning during fast unit checks. - Tests can mock providers (useful for unit-like behavior) and can expect failures using
expect_failuresto validate negative cases. (developer.hashicorp.com)
CI checks and static analysis
Automate quality checks in CI before publishing or merging:
- terraform fmt and terraform validate — syntax and basic validation. (developer.hashicorp.com)
- tflint — lints for provider-specific best practices and naming conventions. (github.com)
- static security scanners (tfsec / Trivy integration) — detect common IaC security misconfigurations. Note: tfsec functionality has been integrated into Trivy in recent toolchain changes; use the current, supported scanner in your CI. (aquasecurity.github.io)
- terraform test — run module tests. Use plan-mode tests for quick guardrails and apply-mode tests in longer-running pipelines (with strict clean-up policies and dedicated short-lived accounts to avoid surprises). (developer.hashicorp.com)
CI tips:
- Keep tests that create cloud resources gated (longer runtime) and run them against a non-production account.
- Keep format, lint, and static security checks as blocking, fast checks on pull requests.
Publishing and versioning (brief)
When a module is ready for sharing, either publish to a private registry (Terraform Cloud’s private module registry) or the public Terraform Registry. Best practice is to maintain a dedicated repository per module, follow the registry naming conventions, tag releases with semantic versioning (vMAJOR.MINOR.PATCH), and include a clear README and CHANGELOG for consumers. HashiCorp provides a publish flow and naming guidance in the registry documentation and tutorials. (registry.terraform.io)
Also:
- Use semantic versioning to let consumers pick ranges safely.
- Include
versions.tfin each release so consumers know required Terraform/provider versions. (deepwiki.com)
Small checklist for a first public-ready module
- Clean file layout: main.tf, variables.tf, outputs.tf, versions.tf, README.md. (developer.hashicorp.com)
- Friendly variable descriptions and validation blocks for immediate feedback. (developer.hashicorp.com)
- At least one
.tftest.hclto assert critical behavior (useplanfor quick tests). (developer.hashicorp.com) - CI that runs fmt, validate, lint, security scan, and tests (separate fast and slow pipelines). (github.com)
- Semantic versioning, tags, and a CHANGELOG for releases when publishing to a registry. (oneuptime.com)
Final notes
A first module does not need to be perfect — start small, make the module’s intent obvious in README.md, and include tests that prove the behavior you care about. Terraform’s native testing framework reduces friction because tests are written in HCL alongside the module and run with terraform test. For higher-level integration testing or more flexible assertions, existing frameworks (Terratest, external Go tests, or policy checks) remain options; choose what fits your team’s tooling and language preferences. (hashicorp.com)
References:
- HashiCorp Modules and module creation patterns. (developer.hashicorp.com)
- Terraform language: variables and custom validation. (developer.hashicorp.com)
- Terraform testing:
terraform test, test file format, and examples. (developer.hashicorp.com) - Terraform Registry and publishing guidance. (registry.terraform.io)
- Linters and scanners: TFLint and tfsec/Trivy information. (github.com)
This compact pattern—clear layout, validation, tests, and CI—helps produce modules that are safe to share and simple to maintain.