Lab 02: Module Publishing Capstone
Overview
This is your capstone project for the Terraform course. You will design, build, document, test, and publish a reusable Terraform module to the public Terraform Registry.
This is a real portfolio piece - anyone will be able to find and use your module at registry.terraform.io/modules/YOUR-USERNAME/MODULE-NAME/aws.
Time: 2 class sessions + homework
Cost: Varies by module choice ($0 - $5)
Difficulty: Advanced
Learning Objectives
By the end of this lab, you will be able to:
- Design a module interface (inputs, outputs, resource composition)
- Write production-quality Terraform code with validation
- Generate documentation using
terraform-docs - Write Terraform native tests (
.tftest.hcl) - Publish a module to the Terraform Registry
- Create a portfolio artifact you can reference in job applications
New Tools You'll Learn
This capstone introduces two important tools that professional Terraform developers use daily. Let's understand what they are before diving in.
Tool 1: terraform-docs ๐
What is it?
terraform-docs is a utility that automatically generates documentation for your Terraform modules. Instead of manually writing and maintaining a README that lists all your variables, outputs, and requirements, terraform-docs reads your .tf files and generates beautiful, accurate documentation.
Why does it matter?
- Consistency: Every module's docs look the same
- Accuracy: Docs are generated from code, so they can't get out of sync
- Time savings: No manual documentation updates when you add a variable
- Industry standard: Most public modules use terraform-docs
What does it produce?
Running terraform-docs markdown . generates a README with:
- A table of all input variables (name, description, type, default, required)
- A table of all outputs (name, description)
- Provider requirements
- Module requirements
Example output:
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| bucket_name | Name of the S3 bucket | `string` | n/a | yes |
| environment | Environment (dev/staging/prod) | `string` | `"dev"` | no |
| enable_versioning | Enable bucket versioning | `bool` | `true` | no |
## Outputs
| Name | Description |
|------|-------------|
| bucket_arn | ARN of the created bucket |
| bucket_id | Name of the created bucket |Learn more:
- terraform-docs Official Site
- terraform-docs GitHub Repository
- Configuration Reference
- Output Formats
Tool 2: Terraform Test Framework ๐งช
What is it?
Terraform 1.6+ includes a native testing framework that lets you write tests for your modules using .tftest.hcl files. These tests validate that your module behaves correctly before you ship it.
Why does it matter?
- Catch bugs early: Find issues during development, not in production
- Confidence to refactor: Change code knowing tests will catch regressions
- Documentation: Tests show how your module is supposed to work
- Professional practice: Testing is expected in production codebases
How does it work?
You write test files with run blocks. Each run block:
- Sets up variables
- Runs either
planorapply - Checks assertions about the result
Simple example:
# tests/basic.tftest.hcl
# Set variables for all tests in this file
variables {
bucket_name = "test-bucket-12345"
environment = "test"
}
# Test 1: Verify the bucket is created
run "bucket_is_created" {
command = plan # Just plan, don't actually create resources
assert {
condition = aws_s3_bucket.this.bucket == "test-bucket-12345"
error_message = "Bucket should be created with the specified name"
}
}
# Test 2: Verify tags are applied
run "bucket_has_environment_tag" {
command = plan
assert {
condition = aws_s3_bucket.this.tags["Environment"] == "test"
error_message = "Bucket should have Environment tag"
}
}Running tests:
$ terraform test
tests/basic.tftest.hcl... in progress
run "bucket_is_created"... pass
run "bucket_has_environment_tag"... pass
tests/basic.tftest.hcl... tearing down
tests/basic.tftest.hcl... pass
Success! 2 passed, 0 failed.Learn more:
- Terraform Test Documentation
- Write Terraform Tests Tutorial
- Test Command Reference
- Testing Best Practices
The Assignment
Choose Your Domain
Pick ONE of the following AWS service categories and build a module for it:
| Category | Module Ideas | Complexity | Estimated Cost |
|---|---|---|---|
| Compute | EC2 instance with security group | โญโญ | ~$0.01/hr (t3.micro) |
| Compute | Lambda function with IAM role | โญโญ | $0.00 (free tier) |
| Database | DynamoDB table with optional GSI | โญ | $0.00 (on-demand) |
| Database | RDS MySQL/PostgreSQL instance | โญโญโญ | ~$0.02/hr (db.t3.micro) |
| Network | VPC with public subnet | โญโญ | $0.00 |
| Network | Security group with dynamic rules | โญโญ | $0.00 |
| Storage | S3 bucket with lifecycle rules | โญ | $0.00 |
Sandbox Reminder: Stay within AWS Academy sandbox constraints: t3.micro/small for EC2, db.t3.micro/small for RDS, no Multi-AZ, no domain registration.
Module Requirements
Your module MUST include:
1. Standard File Structure
terraform-aws-<your-module>/
โโโ README.md # Auto-generated by terraform-docs
โโโ main.tf # Primary resources (2-5 resources minimum)
โโโ variables.tf # At least 4 input variables
โโโ outputs.tf # At least 3 outputs
โโโ versions.tf # Required providers + Terraform version
โโโ tests/
โโโ basic.tftest.hcl # At least 3 passing tests
2. Input Variables (minimum 4)
- At least 2 required variables (no default)
- At least 2 optional variables (with sensible defaults)
- All variables must have
description - All variables must have explicit
type - At least 1 variable must have a
validationblock
3. Resources (minimum 2)
- Create at least 2 related AWS resources
- Use
localsfor computed values or tag merging - Follow the naming convention: use
thisfor single resources of a type
4. Outputs (minimum 3)
- Output the primary resource's ID
- Output the primary resource's ARN (if applicable)
- Output at least one other useful attribute
- All outputs must have
description
5. Documentation
- Generated
README.mdusingterraform-docs - Must include: description, usage example, inputs table, outputs table
6. Tests (minimum 3)
- At least 3
runblocks in your test file - Tests must use
command = plan(no apply needed) - Tests must all pass
Module Ideas by Category
Compute: terraform-aws-ec2-webserver
Create an EC2 instance configured as a basic web server.
Resources:
aws_instance- The EC2 instanceaws_security_group- Allow HTTP/HTTPS/SSHaws_eip(optional) - Elastic IP
Variables:
instance_name(required) - Name tag for the instanceinstance_type(optional, default: "t3.micro") - Instance sizeallowed_ssh_cidr(required) - CIDR block allowed to SSHenable_elastic_ip(optional, default: false) - Whether to attach an EIP
Outputs:
instance_idpublic_ipsecurity_group_id
Compute: terraform-aws-lambda-function
Create a Lambda function with proper IAM role.
Resources:
aws_lambda_function- The functionaws_iam_role- Execution roleaws_iam_role_policy_attachment- Basic execution policyaws_cloudwatch_log_group- Log group for the function
Variables:
function_name(required)runtime(optional, default: "python3.11")handler(optional, default: "index.handler")timeout(optional, default: 30)
Outputs:
function_arnfunction_namerole_arnlog_group_name
Database: terraform-aws-dynamodb-table
Create a DynamoDB table with optional Global Secondary Index.
Resources:
aws_dynamodb_table- The table
Variables:
table_name(required)hash_key(required) - Partition key namehash_key_type(optional, default: "S") - S, N, or Benable_point_in_time_recovery(optional, default: true)tags(optional)
Outputs:
table_idtable_arntable_name
Database: terraform-aws-rds-mysql
Create an RDS MySQL instance (sandbox-safe).
Resources:
aws_db_instance- The RDS instanceaws_db_subnet_group- Subnet groupaws_security_group- Database security group
Variables:
identifier(required) - DB instance identifierdb_name(required) - Initial database nameusername(required) - Master usernameinstance_class(optional, default: "db.t3.micro")allocated_storage(optional, default: 20)vpc_id(required) - VPC to deploy intosubnet_ids(required) - List of subnet IDs
Outputs:
endpointportdb_instance_id
โ ๏ธ Note: For the password, userandom_passwordresource or accept it as a sensitive variable. Never hardcode!
Network: terraform-aws-vpc-simple
Create a basic VPC with public subnet.
Resources:
aws_vpc- The VPCaws_subnet- Public subnetaws_internet_gateway- Internet gatewayaws_route_table- Route table with internet routeaws_route_table_association- Associate subnet with route table
Variables:
vpc_name(required)vpc_cidr(optional, default: "10.0.0.0/16")public_subnet_cidr(optional, default: "10.0.1.0/24")availability_zone(required)
Outputs:
vpc_idvpc_cidrpublic_subnet_idinternet_gateway_id
Network: terraform-aws-security-group
Create a security group with dynamic ingress/egress rules.
Resources:
aws_security_group- The security group (usingdynamicblocks)
Variables:
name(required)description(required)vpc_id(required)ingress_rules(required) - List of objects:{port, protocol, cidr_blocks, description}tags(optional)
Outputs:
security_group_idsecurity_group_arnsecurity_group_name
Tip: This is a great module for demonstrating
dynamicblocks!
Storage: terraform-aws-s3-secure
Create an S3 bucket with security best practices.
Resources:
aws_s3_bucketaws_s3_bucket_versioningaws_s3_bucket_server_side_encryption_configurationaws_s3_bucket_public_access_blockaws_s3_bucket_lifecycle_configuration(optional)
Variables:
bucket_name(required)enable_versioning(optional, default: true)enable_lifecycle_rules(optional, default: false)noncurrent_version_expiration_days(optional, default: 90)tags(optional)
Outputs:
bucket_idbucket_arnbucket_domain_name
Timeline
Class 1: Design & Build
Before Class:
- Review module ideas above
- Pick your module category
- Sketch out your variables, resources, and outputs
In Class (2 hours):
- Create your GitHub repository with correct naming:
terraform-aws-<module-name> - Build your module (main.tf, variables.tf, outputs.tf, versions.tf)
- Test locally with
terraform init,validate,plan - Get it working end-to-end
Deliverable: Working module that can be applied
Homework: Documentation & Tests
Between Classes (1-2 hours):
- Install terraform-docs (see guide below)
- Generate README:
terraform-docs markdown . > README.md - Write at least 3 tests in
tests/basic.tftest.hcl - Run tests:
terraform test - Push to GitHub
Deliverable: Module with README.md and passing tests
Class 2: Publish & Demo
In Class (1.5 hours):
- Connect GitHub to Terraform Registry
- Create version tag:
git tag v1.0.0 && git push --tags - Publish module to Registry
- Verify module appears on registry.terraform.io
- Demo: Show a classmate can use your module with
source = "your-username/module-name/aws" - Update your portfolio to link to your published module
Deliverable: Published module on Terraform Registry
Deep Dive: Using terraform-docs
Installation
# macOS
brew install terraform-docs
# Linux / WSL (download binary)
curl -sSLo ./terraform-docs.tar.gz https://terraform-docs.io/dl/v0.18.0/terraform-docs-v0.18.0-linux-amd64.tar.gz
tar -xzf terraform-docs.tar.gz
chmod +x terraform-docs
sudo mv terraform-docs /usr/local/bin/
# Via Go (if you have Go installed)
go install github.com/terraform-docs/terraform-docs@latest
# Verify installation
terraform-docs --versionBasic Usage
The simplest usage generates Markdown from your current directory:
# Generate markdown and print to stdout
terraform-docs markdown .
# Generate markdown and save to README.md
terraform-docs markdown . > README.md
# Generate markdown table format (most common)
terraform-docs markdown table . > README.mdWhat terraform-docs Reads
terraform-docs parses your .tf files and extracts:
| From | What it extracts |
|---|---|
variables.tf |
Variable name, description, type, default, required status |
outputs.tf |
Output name, description, sensitive status |
versions.tf |
Required Terraform version, required providers |
main.tf |
Resources and data sources used |
This is why good descriptions matter! Whatever you write in description shows up in your docs:
# This description becomes your documentation
variable "bucket_name" {
description = "Name of the S3 bucket. Must be globally unique."
type = string
}Customizing Output
You can create a .terraform-docs.yml file to customize the output:
# .terraform-docs.yml
formatter: "markdown table"
sections:
show:
- header
- inputs
- outputs
- providers
- requirements
content: |-
{{ .Header }}
## Usage
```hcl
module "example" {
source = "your-username/module-name/aws"
version = "1.0.0"
bucket_name = "my-bucket"
}{{ .Requirements }}
{{ .Providers }}
{{ .Inputs }}
{{ .Outputs }}
Then just run:
```bash
terraform-docs . # It will find the config file automatically
Documentation References
Deep Dive: Writing Terraform Tests
Test File Location
Tests live in a tests/ directory in your module root:
terraform-aws-my-module/
โโโ main.tf
โโโ variables.tf
โโโ outputs.tf
โโโ tests/
โโโ basic.tftest.hcl # Basic functionality tests
โโโ validation.tftest.hcl # Input validation tests (optional)
Anatomy of a Test File
# tests/basic.tftest.hcl
# ============================================
# PROVIDER CONFIGURATION (required for AWS)
# ============================================
provider "aws" {
region = "us-east-1"
}
# ============================================
# GLOBAL VARIABLES
# ============================================
# These apply to all run blocks unless overridden
variables {
bucket_name = "test-bucket-abc123"
environment = "test"
}
# ============================================
# TEST: Resource Creation
# ============================================
run "bucket_is_created" {
# Use 'plan' to test without creating real resources
command = plan
# Assert checks a condition and fails if false
assert {
condition = aws_s3_bucket.this.bucket == "test-bucket-abc123"
error_message = "Bucket name should match the input variable"
}
}
# ============================================
# TEST: Default Values
# ============================================
run "versioning_enabled_by_default" {
command = plan
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
error_message = "Versioning should be enabled by default"
}
}
# ============================================
# TEST: Tags
# ============================================
run "required_tags_are_applied" {
command = plan
assert {
condition = aws_s3_bucket.this.tags["Environment"] == "test"
error_message = "Environment tag should be set"
}
# You can have multiple asserts in one run block
assert {
condition = contains(keys(aws_s3_bucket.this.tags), "ManagedBy")
error_message = "ManagedBy tag should be present"
}
}The command Option
| Command | What it does | Use when |
|---|---|---|
plan |
Runs terraform plan only |
Most tests - fast, no resources created |
apply |
Runs terraform apply |
Integration tests that need real resources |
Recommendation: Use command = plan for this capstone. It's fast and doesn't cost money.
Writing Good Assertions
Check equality:
condition = aws_instance.this.instance_type == "t3.micro"Check a value is not empty:
condition = aws_s3_bucket.this.arn != ""Check a string contains something:
condition = strcontains(aws_s3_bucket.this.bucket, "prod")Check a key exists in a map:
condition = contains(keys(aws_s3_bucket.this.tags), "Environment")Check a list has items:
condition = length(aws_security_group.this.ingress) > 0Combine conditions (AND):
condition = aws_instance.this.instance_type == "t3.micro" && aws_instance.this.tags["Environment"] == "dev"Safe navigation with try():
# Won't crash if the path doesn't exist
condition = try(aws_s3_bucket_versioning.this.versioning_configuration[0].status, "") == "Enabled"Testing Variable Validation
You can test that your validation blocks work correctly:
run "rejects_invalid_bucket_name" {
command = plan
variables {
bucket_name = "ab" # Too short - should fail validation
}
# Tell Terraform we EXPECT this to fail
expect_failures = [
var.bucket_name
]
}Running Tests
# Run all tests
terraform test
# Run with verbose output (shows each assertion)
terraform test -verbose
# Run only tests in a specific file
terraform test -filter=tests/basic.tftest.hcl
# Run tests and show the plan output
terraform test -verboseExpected Output
$ terraform test
tests/basic.tftest.hcl... in progress
run "bucket_is_created"... pass
run "versioning_enabled_by_default"... pass
run "required_tags_are_applied"... pass
tests/basic.tftest.hcl... tearing down
tests/basic.tftest.hcl... pass
Success! 3 passed, 0 failed.
Documentation References
- Terraform Tests Overview
- Tests Tutorial
- Test Command Reference
- Assertions Reference
- Testing Best Practices
Step-by-Step: Publishing to the Registry
Step 1: Create GitHub Repository
Your repository must follow this naming convention:
terraform-<PROVIDER>-<NAME>
Examples:
terraform-aws-ec2-webserverterraform-aws-dynamodb-tableterraform-aws-vpc-simple
โ ๏ธ The registry will reject repositories that don't match this pattern!
Create the repo on GitHub:
- Go to github.com โ New Repository
- Name:
terraform-aws-<your-module-name> - Public (required for registry)
- Initialize with README: No (you'll generate this)
- Create repository
Clone and add your code:
git clone git@github.com:YOUR-USERNAME/terraform-aws-<module-name>.git
cd terraform-aws-<module-name>
# Copy your module files here
# main.tf, variables.tf, outputs.tf, versions.tf, tests/Step 2: Generate Documentation
# Generate your README
terraform-docs markdown table . > README.md
# Edit README.md to add a usage example at the top
# (The generated content will be below your custom intro)Recommended README structure:
# terraform-aws-<your-module>
Brief description of what this module creates.
## Usage
```hcl
module "example" {
source = "YOUR-USERNAME/<module-name>/aws"
version = "1.0.0"
# Required variables
name = "my-resource"
# Optional variables
environment = "prod"
}Requirements
...
Inputs
...
Outputs
...
### Step 3: Write and Run Tests
```bash
# Create tests directory
mkdir -p tests
# Create your test file (use the template in starter-templates/)
# Edit tests/basic.tftest.hcl
# Run tests
terraform test
Step 4: Create versions.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0.0"
}
}
}Step 5: Commit and Tag
git add .
git commit -m "Initial module release"
git push origin main
# Create a semantic version tag
git tag v1.0.0
git push --tagsStep 6: Connect to Terraform Registry
- Go to registry.terraform.io
- Click "Sign In" โ Sign in with GitHub
- Click "Publish" โ "Module"
- Select your repository from the list
- Click "Publish Module"
The registry will:
- Validate your repository name follows the pattern
- Check for required files (main.tf, etc.)
- Parse your version tags
- Generate documentation from your code
Step 7: Verify Publication
Your module should now be live at:
https://registry.terraform.io/modules/YOUR-USERNAME/<module-name>/aws/latest
Test that someone can use it:
module "test" {
source = "YOUR-USERNAME/<module-name>/aws"
version = "1.0.0"
# ... variables
}Grading Rubric
See RUBRIC.md for detailed grading criteria.
| Category | Points |
|---|---|
| Module Structure & Code Quality | 25 |
| Variables & Validation | 20 |
| Resources & Outputs | 20 |
| Documentation (terraform-docs) | 15 |
| Tests (terraform test) | 15 |
| Registry Publication | 5 |
| Total | 100 |
Submission
Your submission consists of:
- GitHub Repository URL:
github.com/YOUR-USERNAME/terraform-aws-<module-name> - Terraform Registry URL:
registry.terraform.io/modules/YOUR-USERNAME/<module-name>/aws - Brief Write-up (in your PR or SUBMISSION.md):
- What module did you build and why?
- What was the hardest part?
- What would you add in v2.0.0?
Create a PR to this repo with a SUBMISSION.md file in week-02/lab-02-module-publishing-capstone/submissions/YOUR-NAME/:
week-02/lab-02-module-publishing-capstone/
โโโ submissions/
โโโ YOUR-NAME/
โโโ SUBMISSION.md
Resources
Terraform Registry
terraform-docs
Terraform Testing
Module Best Practices
FAQ
Q: Can I build something not on the list?
A: Yes! As long as it meets the minimum requirements (2+ resources, 4+ variables, 3+ outputs, 3+ tests) and stays within sandbox constraints.
Q: What if my module costs money?
A: Use terraform plan to validate without creating resources. Only apply briefly to verify it works, then destroy immediately. Most modules can be validated with just plan, and that's all the tests require.
Q: Can I work with a partner?
A: No, this is an individual capstone. However, you can review each other's code and help debug.
Q: What if the Registry rejects my module?
A: Common issues:
- Repository name doesn't match
terraform-<PROVIDER>-<NAME>pattern - No version tags (must be
v1.0.0format) - Missing required files
- Repository is private (must be public)
Q: How long does publishing take?
A: Usually instant. The registry webhooks respond to new tags within seconds.
Q: Do I need to run terraform apply for my tests?
A: No! Using command = plan is sufficient for this capstone. The tests validate your configuration without creating real resources.
Q: What if terraform-docs isn't generating my variable descriptions?
A: Make sure every variable has a description field. terraform-docs can only document what you describe!
Portfolio Value
After completing this lab, you can add to your resume/LinkedIn:
Published Terraform Module - Designed, tested, and published a reusable Terraform module to the HashiCorp Terraform Registry. Module includes input validation, automated documentation, and native Terraform tests.
๐ registry.terraform.io/modules/username/module-name/aws
This demonstrates:
- Infrastructure as Code expertise
- Understanding of module design patterns
- Documentation discipline
- Testing practices
- Ability to publish professional-grade open source work