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

What you’ll get from this article

Module scope and design

Scaffold: recommended file layout Use a consistent structure so other people (and the Terraform Registry) can inspect and publish your module:

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 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?

Best practices and hygiene

Versioning and publishing

Common pitfalls and how to avoid them

A short CI suggestion (example)

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

If you want, I can: