on
Write your first reusable Terraform module: a simple S3 static-website example
Writing your first Terraform module is like learning a new song: you start with a simple melody, learn the chords, then compose variations that fit different keys. For infrastructure, that “melody” is a small, well-scoped module you can reuse across projects. In this article we’ll pick a beginner-friendly, practical example — a reusable module that creates an S3 bucket configured to host a static website — and walk through the structure, the essential files, and the reasoning behind decisions beginners miss. HashiCorp’s tutorials use an S3 static-site example for exactly this purpose, so it’s a relevant place to start. (developer.hashicorp.com)
Why this module?
- It’s small and focused: one main resource (an S3 bucket) and a couple of supporting bits (policy, public access block, website config).
- It teaches inputs, outputs, and how to hide implementation details while exposing only what callers need — the core benefits of modules.
- It maps well to real projects where small, repeatable infra components are useful (landing pages, documentation hosts, asset buckets).
Module design principles (short list)
- Keep the module narrowly focused: one responsibility = easier to test and reuse.
- Expose simple inputs and outputs; don’t force callers to know implementation details.
- Include metadata (README, examples) and automate formatting and linting. These align with widely recommended Terraform module styles and best practices. (cloud.google.com)
What your module will do
- Create an S3 bucket with:
- Server-side encryption
- Versioning toggle
- Website hosting configuration (index, error documents)
- Optional public read policy (configurable)
- Export the bucket name and website endpoint as outputs
Directory layout A minimal, standard module layout:
- modules/s3-static-website/
- main.tf
- variables.tf
- outputs.tf
- README.md
- examples/
- simple/
- main.tf
- simple/
This mirrors the recommended structure in Terraform docs: keep only Terraform files in the module root and include examples and docs for users. (cloud.google.com)
Key files and snippets
1) variables.tf — only what callers need Expose a small, opinionated surface:
variable "bucket_name" {
type = string
description = "Name of the S3 bucket to create (must be globally unique)"
}
variable "enable_public_read" {
type = bool
description = "Whether to add a public-read bucket policy (optional)"
default = false
}
variable "versioning" {
type = bool
description = "Enable S3 versioning"
default = true
}
variable "index_document" {
type = string
description = "Index document for website hosting"
default = "index.html"
}
Keep defaults sensible (e.g., versioning enabled) so callers don’t have to configure everything.
2) main.tf — the implementation Keep resources clear and annotated.
resource "aws_s3_bucket" "site" {
bucket = var.bucket_name
acl = var.enable_public_read ? "public-read" : "private"
website {
index_document = var.index_document
error_document = "error.html"
}
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
versioning {
enabled = var.versioning
}
}
resource "aws_s3_bucket_policy" "public" {
count = var.enable_public_read ? 1 : 0
bucket = aws_s3_bucket.site.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.site.arn}/*"
}]
})
}
Notes:
- Use count to make the public policy optional (keeps module callers safe by default).
- Encrypt by default — small, important security win.
3) outputs.tf — what callers will use
output "bucket_name" {
value = aws_s3_bucket.site.id
description = "The name of the S3 bucket"
}
output "website_endpoint" {
value = aws_s3_bucket.site.website_endpoint
description = "The S3 website endpoint to use in DNS or redirects"
}
4) README.md and examples Include a short example showing how to call the module from a root configuration. Good examples massively reduce friction for module consumers.
Example caller (modules referenced locally for the tutorial):
module "docs_site" {
source = "../modules/s3-static-website"
bucket_name = "my-company-docs-example"
enable_public_read = true
}
Module publishing and versioning When your module is ready to share, consider tagging releases and publishing to a registry (public or private). Declare required providers and Terraform version inside the module only when necessary for the module’s implementation; this helps with reproducibility and avoids surprises when modules get consumed. Terraform’s module and provider guidance explains how modules are distributed and versioned. (developer.hashicorp.com)
Testing, formatting, and CI Treat a module like any other piece of code:
- terraform fmt and terraform validate in pre-commit hooks
- tflint or checkov for static checks
- simple smoke tests: apply in an ephemeral account or use tools like Terratest for programmatic tests Tooling and CI workflows for Terraform are well-covered by community and vendor posts — having an automated pipeline for linting and validation will prevent small mistakes from becoming production problems. (spacelift.io)
Design trade-offs and tips
- Public vs private: prefer private by default. Make public-read optional and explicit.
- Abstraction vs flexibility: a module should abstract repetitive plumbing (encryption, tagging) but not try to be everything to everyone. AWS prescriptive guidance encourages abstracting unnecessary implementation details while keeping modules understandable. (docs.aws.amazon.com)
- Keep IAM and networking decisions out of tiny modules unless that’s their purpose. A module that does “one thing well” composes more easily.
Common beginner mistakes
- Overexposing every internal attribute as an input: makes the module hard to maintain.
- Hardcoding names that cause collisions across environments: prefer parameterized names or allow Terraform to create unique suffixes if appropriate.
- Forgetting to document required permissions for the caller’s AWS principal (for example, who can create buckets, policies, etc.).
Analogies and mental models Think of the module like a small song arrangement: you decide the core melody (the S3 bucket), set consistent tempo and key (encryption, versioning), and allow players to choose the instrument (public vs private) via a few knobs (variables). When the arrangement is clean, you can drop it into any performance (project) and it sounds right.
References and further reading
- HashiCorp’s “Build and use a local module” tutorial — an approachable guide that uses an S3 static website example. (developer.hashicorp.com)
- Terraform language modules and module publishing docs — for guidance on the module block and module registry usage. (developer.hashicorp.com)
- Google Cloud’s Terraform module best practices — concise rules about module structure, metadata, and why examples matter. (cloud.google.com)
- Spacelift’s practical checklist for Terraform best practices and CI workflows. (spacelift.io)
- AWS prescriptive guidance for Terraform AWS Provider — notes on abstraction, provider usage, and recommended patterns. (docs.aws.amazon.com)
Parting note Start small, document clearly, and keep the module focused. You’ll learn more about what to expose and what to hide as you reuse the module across projects — the same way a musician sharpens arrangements by playing them in different venues. With this S3 static-site module you get a compact, real-world example that teaches the essential module patterns without overwhelming complexity.