on
Writing your first Terraform module: build a reusable AWS S3 bucket and test it with Terraform mocks
Writing your first Terraform module is like composing a short song: you pick a clear theme, structure the parts (verse, chorus), rehearse, then share. In Terraform-land the theme is a single, well-scoped piece of infrastructure; the structure is the module file layout; rehearsal is testing; and sharing is publishing a versioned module. In this walkthrough we’ll build a small, useful Terraform module that provisions an AWS S3 bucket (with encryption, versioning, lifecycle, and logging options), and show how to test it locally using Terraform’s mock providers so you can test without touching real cloud accounts.
Why this topic is timely
- Terraform’s built-in testing framework gained powerful mocking and test improvements (so you can run fast, credential-free tests) starting with Terraform 1.7 and continuing improvements in later releases. This makes it a great time to learn module authoring and testing together. (hashicorp.com)
What you’ll get from this article
- A recommended module file layout and coding pattern.
- A minimal S3 module (main.tf, variables.tf, outputs.tf).
- A .tftest file that uses mock providers to assert behavior.
- Best-practice tips for validation, documentation, and publishing.
Module scope and design
- Goal: one reusable module that creates an S3 bucket with sensible defaults and
safe options:
- server-side encryption (SSE) enabled by default
- optional versioning
- optional lifecycle rule for object expiration
- optional access logging to another bucket
- outputs: bucket name, ARN
- Keep the module focused. Small, composable modules are easier to reuse than a monolith that tries to do everything. Think of the module as a single musical riff you can plug into many songs. (developer.hashicorp.com)
Scaffold: recommended file layout Use a consistent structure so other people (and the Terraform Registry) can inspect and publish your module:
- terraform-aws-s3-bucket/
- main.tf
- variables.tf
- outputs.tf
- README.md
- examples/simple/ (example usage + terraform.tfvars.example)
- testing/
- bucket_test.tftest.hcl
This standard layout helps automation and registry tooling parse inputs/outputs and examples. (developer.hashicorp.com)
Minimal module code (essential parts) Below are simplified examples — copy into files and adapt.
main.tf
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
acl = var.acl
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = var.enable_sse ? "AES256" : null
}
}
}
versioning {
enabled = var.enable_versioning
}
lifecycle_rule {
id = "auto-expire"
enabled = var.lifecycle_enabled
expiration {
days = var.lifecycle_days
}
prefix = var.lifecycle_prefix
}
tags = merge(var.default_tags, var.tags)
}
resource "aws_s3_bucket_logging" "log" {
count = var.enable_logging ? 1 : 0
bucket = aws_s3_bucket.this.id
target_bucket = var.logging_target_bucket
target_prefix = "${var.bucket_name}/"
}
variables.tf
variable "bucket_name" {
type = string
description = "Bucket name (must be globally unique)"
}
variable "acl" {
type = string
default = "private"
description = "ACL for the bucket"
validation {
condition = contains(["private","log-delivery-write","public-read"], var.acl)
error_message = "ACL must be one of private, log-delivery-write, or public-read"
}
}
variable "enable_sse" {
type = bool
default = true
}
variable "enable_versioning" {
type = bool
default = false
}
variable "enable_logging" {
type = bool
default = false
}
variable "logging_target_bucket" {
type = string
description = "Bucket to store access logs (if enable_logging)"
default = ""
}
variable "lifecycle_enabled" {
type = bool
default = false
}
variable "lifecycle_days" {
type = number
default = 365
}
variable "lifecycle_prefix" {
type = string
default = ""
}
variable "default_tags" {
type = map(string)
default = {}
}
variable "tags" {
type = map(string)
default = {}
}
outputs.tf
output "bucket_id" {
value = aws_s3_bucket.this.id
description = "The bucket ID (name)."
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
description = "The bucket ARN."
}
How someone consumes the module (example)
module "data_bucket" {
source = "./" # or "my-org/terraform-aws-s3-bucket/aws" from registry
bucket_name = "my-app-data-bucket-12345"
enable_versioning = true
tags = {
Team = "Platform"
Env = "staging"
}
}
Testing locally with Terraform mocks One of the most useful modern additions is mock providers in Terraform tests. With a mock provider you can run assertions against your module without real AWS credentials or resources — tests run fast and safely. This is great for module authors who want quick feedback and CI-level checks. (hashicorp.com)
Create a test file testing/bucket_test.tftest.hcl:
mock_provider "aws" {}
run "basic_bucket" {
module {
source = "../"
variables = {
bucket_name = "test-bucket"
enable_versioning = true
}
}
assert {
condition = aws_s3_bucket.this.versioning[0].enabled == true
error_message = "versioning should be enabled"
}
assert {
condition = aws_s3_bucket.this.server_side_encryption_configuration[0].rule[0].apply_server_side_encryption_by_default[0].sse_algorithm == "AES256"
error_message = "SSE should be AES256 by default"
}
}
Run the test:
- terraform init (to ensure required providers are available)
- terraform test ./testing
Terraform will run the test with a mocked aws provider and evaluate your assertions. The mocks can be customized to return specific computed attributes if your logic depends on them. (developer.hashicorp.com)
Why test with mocks?
- Fast feedback: no real provisioning delays.
- Safe: no accidental charges or resource sprawl.
- Deterministic: you can override provider-returned values for predictable asserts.
Best practices and hygiene
- Inputs: validate types and constraints at the module boundary (variable validation). This stops invalid usage early. (developer.hashicorp.com)
- Defaults: only give sensible defaults; do not hide required values (e.g., do not default vpc_id if callers must provide it).
- Secrets: never hardcode secrets in the module. Use sensitive variables and external secret managers. Mark sensitive variables to prevent leaks in logs. (blog.brainboard.co)
- Keep modules small and composable. Compose more complex infrastructure from several focused modules. (developer.hashicorp.com)
- Formatting and linting: run terraform fmt and terraform validate in CI; consider tflint for style and security rules.
- Documentation: include README with examples and inputs/outputs. Tools like terraform-docs can auto-generate parts of the README from your variables and outputs. (developer.hashicorp.com)
Versioning and publishing
- When your module works, tag releases using semantic versioning (v1.0.0 style). The Terraform Registry (public or private) uses tags to publish module versions and extract docs. Follow the naming and repository rules if you want to publish to the public registry. (developer.hashicorp.com)
Common pitfalls and how to avoid them
- Hidden dependencies: don’t rely on implicit provider configurations or global state; make inputs explicit and document them. (cloud.google.com)
- Monolith modules: if a module grows beyond a focused responsibility, split it into submodules or smaller modules and compose at higher level.
- State surprises: avoid putting unrelated resources into the same root module state. Keep state per environment or per application where appropriate. Root-module best practices often recommend limiting resources per state for speed and safety. (cloud.google.com)
A short CI suggestion (example)
- Workflow steps:
- terraform fmt -check
- terraform init
- terraform validate
- terraform test ./testing
- terraform-docs markdown . > README.md (or to part of README) This gives formatting, static checking, unit-like tests, and up-to-date docs in CI.
Final thoughts (a little musical metaphor) Think of your first Terraform module as a short demo track: simple, repeatable, and tested. Play it often, iterate on it, and keep it short enough that others can remix it. With Terraform’s mocking abilities you can rehearse without renting studio time — run fast tests, get confidence, and publish a clean, versioned module that other teams will enjoy using.
Helpful references
- Terraform 1.7 announcement (mocking and testing improvements). (hashicorp.com)
- Terraform testing: mock providers and overrides documentation. (developer.hashicorp.com)
- Module creation and recommended patterns. (developer.hashicorp.com)
- Publishing modules to the Terraform Registry. (developer.hashicorp.com)