on
Writing your first Terraform module: build a reusable S3 static-website module
Modules are the unit of reuse in Terraform — they let you package a small, well-documented piece of infrastructure as an API you and your team can call from other configurations. In this short guide you’ll see a practical, modern pattern for your first module: an S3-backed static website (optionally fronted by CloudFront). The examples use current Terraform module conventions (files, documentation, validation and typed outputs) so the module is reusable, safe, and easy to maintain. (docs.hashicorp.com)
What this module does (and why it’s a good first module)
A “static-website” module is a compact, real-world example that includes:
- One or more provider constraints and a pinned Terraform version (so state and tooling behave predictably).
- A small set of configurable inputs (bucket name, ACL/public access, CloudFront enable flag, domain name).
- Outputs you can feed into other modules (bucket ID, website URL, CloudFront domain).
- Documentation, examples, and a minimal test/validate approach.
This scope keeps the module focused and reusable: network and DNS can be handled by other modules. The approach follows HashiCorp and cloud-provider guidance for module scope and structure. (hashicorp.com)
Recommended repository layout
Terraform tooling and the public registry expect a simple, predictable structure. At minimum, include these files and folders at the module root:
- README.md — short description and examples.
- LICENSE — if you publish or share.
- main.tf — where resources and nested module calls live.
- variables.tf — all variable declarations and validation.
- outputs.tf — outputs with descriptions (and types, when useful).
- examples/ — one or more example uses with their own main.tf + tfvars.
- tests/ (optional) — smoke tests or tftest files.
This layout is recommended by Terraform documentation and helps tooling generate docs and index modules properly. (docs.hashicorp.com)
Key design ideas before you start coding
- Keep the module focused (one responsibility). Split responsibilities if the module grows big. (hashicorp.com)
- Use descriptive variable names and short descriptions — tooling will surface these in generated docs. (docs.hashicorp.com)
- Validate inputs early using variable validation blocks; modern Terraform supports validation rules that can reference other variables which lets you express constraints like “if enable_cloudfront is true, domain_name must be provided.” (discuss.hashicorp.com)
- Pin Terraform and provider versions in a terraform { } block so users don’t unexpectedly upgrade to incompatible versions. (developer.hashicorp.com)
Minimal code layout (examples)
Below are short snippets you can use as a template inside your module. These are intentionally simple so you can focus on the pattern.
versions.tf
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.50.0"
}
}
}
variables.tf
variable "name" {
description = "Logical name prefix for resources"
type = string
}
variable "enable_cloudfront" {
description = "Create CloudFront distribution in front of the bucket"
type = bool
default = false
}
variable "domain_name" {
description = "Optional custom domain for CloudFront (required if enable_cloudfront)"
type = string
default = ""
validation {
condition = !(var.enable_cloudfront && var.domain_name == "")
error_message = "When enable_cloudfront is true you must provide domain_name."
}
}
The validation block above leverages modern Terraform validation where a rule refers to another variable — a handy pattern for cross-field constraints. (discuss.hashicorp.com)
main.tf (essential parts)
resource "aws_s3_bucket" "site" {
bucket = "${var.name}-site"
acl = "public-read"
website {
index_document = "index.html"
error_document = "404.html"
}
# server-side encryption and public-access-block settings would go here
}
Optional CloudFront (guarded by enable_cloudfront):
resource "aws_cloudfront_distribution" "cdn" {
count = var.enable_cloudfront ? 1 : 0
# minimal CloudFront config using aws_s3_bucket.site.bucket_regional_domain_name as origin
}
outputs.tf
output "bucket_id" {
description = "S3 bucket id"
value = aws_s3_bucket.site.id
type = string
}
output "website_endpoint" {
description = "S3 static website endpoint (when not using CloudFront)"
value = aws_s3_bucket.site.website_endpoint
type = string
}
Declaring types on outputs (e.g., type = string) makes the module’s contract clearer and is supported by modern Terraform language features. (kb.ekarisky.com)
Documentation and examples
- Put a short README.md describing purpose, inputs, outputs and at least one example that shows both the minimal and the “CloudFront + domain” variants. The module README is the first place consumers look; add a concise example
examples/completethat callers can copy. Terraform tooling will also auto-generate variable and output docs from your files. (docs.hashicorp.com)
Versioning and publishing notes
- Treat each published module version as immutable. Use semantic versioning for releases (patch/minor/major) and document breaking changes in a CHANGELOG. The Terraform docs recommend pinning provider versions and a compatible Terraform version in your module so consumers have predictable behavior. (developer.hashicorp.com)
Tests and quality checks
- Start with terraform fmt and terraform validate in CI. Add a simple smoke test: a short example that runs terraform init/plan (and optionally apply in a throwaway account) to ensure no syntax or provider issues. The HashiCorp guidance on module design highlights testing and example-driven documentation as core practices for maintainability. (hashicorp.com)
Small but important hardening tips
- Default to enforcing server-side encryption on the S3 bucket and provide variables to opt out only when explicitly needed.
- Use public access block settings instead of ACLs where possible, or make ACLs configurable with clear warnings in the README.
- If CloudFront is used, prefer an Origin Access Control (OAC) or Origin Access Identity (OAI) so the bucket is not publicly readable. (These are implementation details you can add as module features.) Provider docs and popular modules demonstrate the required resources and bindings. (github.com)
Quick checklist for publishing a first module
- Files: main.tf, variables.tf, outputs.tf, README.md, examples/.
- Pin terraform and provider versions in versions.tf.
- Add input validation for cross-field rules (e.g., require domain when CloudFront is enabled). (discuss.hashicorp.com)
- Add typed outputs where the consumer should expect a specific kind of value. (kb.ekarisky.com)
- Add formatting and validation to CI (terraform fmt, terraform validate).
- Write one example that demonstrates the common usage pattern; put it under examples/.
Writing your first module is as much about design — clear inputs, predictable side effects, and good docs — as it is about resources and code. The S3 static-website example is small but touches the important module concerns (scope, validation, outputs, and examples), making it an excellent place to learn how to package Terraform code you and others can rely on. (docs.hashicorp.com)