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:

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)

Terraform tooling and the public registry expect a simple, predictable structure. At minimum, include these files and folders at the module root:

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

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

Versioning and publishing notes

Tests and quality checks

Small but important hardening tips

Quick checklist for publishing a first module

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)