Author a Reusable Terraform Module

Write a reusable Terraform module that deploys S3 static websites.

Introduction

30 Second Summary

Every time you set up a new environment, you rewrite the same infrastructure code with minor tweaks. Copy-paste works until you have ten environments and a bug in one of them.

In this project, you will author a reusable Terraform module from scratch that provisions S3 static websites. You will write the module once and deploy two live environments (dev and prod) from a single configuration.

What You'll Build

A production-quality Terraform module that creates S3 static website infrastructure using typed variables, validation rules, locals, conditional expressions, and for_each over a map of environments.

By the end of this project, you'll have:

  • A reusable module with typed object variables, validation blocks, locals, conditional logic, and outputs following HashiCorp's standard structure.
  • A root configuration that deploys dev and prod websites using for_each over a map, the same pattern real teams use for multi-environment infrastructure.
  • Two live S3 static websites accessible via public URLs, proving your module works end to end.
  • Secret Mission: add a dynamic block for optional CORS configuration and a for expression to generate a summary output.

Are there any prerequisites?

You need Terraform CLI and AWS CLI installed and configured with credentials. You should be comfortable with the terraform init/plan/apply/destroy workflow and have a basic understanding of resources, providers, and variables.

Scaffold the Project Structure

Every reusable Terraform module follows a standard directory layout. The module lives in its own folder with dedicated files for variables, resources, and outputs.

In this step, you will create the full project structure, write the provider configuration that pins your AWS provider version, and run terraform init to confirm everything connects.

In this step, get ready to:
  • Create the project directory structure following HashiCorp's standard module layout.
  • Write the provider configuration to pin Terraform and AWS provider versions.
  • Initialize Terraform to download the AWS provider.
Create the directory structure

HashiCorp's standard module structure separates root configuration from reusable modules. Your root holds the provider config and calls into modules stored under a modules/ subdirectory.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, navigate into it, and scaffold the full directory structure by running these commands:
mkdir terraform-s3-module
cd terraform-s3-module
mkdir -p modules/s3-website/files
touch providers.tf main.tf variables.tf outputs.tf
touch modules/s3-website/main.tf modules/s3-website/variables.tf modules/s3-website/outputs.tf

Windows users

If mkdir -p or touch are not recognized, open Git Bash (installed with Git for Windows) and run the commands there. Most Terraform workflows use a bash-compatible shell.

What does this structure look like?

Your project now has two layers. The root level (providers.tf, main.tf, variables.tf, outputs.tf) is where you call modules and configure providers.

The nested modules/s3-website/ folder is the reusable module itself. It has its own main.tf, variables.tf, and outputs.tf plus a files/ subdirectory for static assets.

  • Confirm all files were created by running:
ls
ls modules/s3-website/
ls modules/s3-website/files/

You should see providers.tf, main.tf, variables.tf, outputs.tf, and a modules folder in the first listing. The second listing shows main.tf, variables.tf, outputs.tf, and files. The third shows an empty files/ directory (we will add index.html next).

Don't see all the files?

Make sure you ran the commands from inside the terraform-s3-module folder. Run pwd to check your current directory. It should end with terraform-s3-module.

If you accidentally created files in the wrong location, delete the folder and start again from cd ~/Desktop.

help me fix my directory structure

  • In your code editor, open the terraform-s3-module folder from your Desktop.
  • Open the file modules/s3-website/files/index.html and paste the following HTML:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Terraform Module Site</title>
    <style>
        body {
            font-family: system-ui, -apple-system, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 2rem;
            background-color: #f0f4f8;
            color: #1a202c;
        }
        h1 { color: #2d3748; }
        .highlight { color: #3182ce; font-weight: bold; }
    </style>
</head>
<body>
    <h1>Hello from a Terraform Module!</h1>
    <p>This site was deployed by a <span class="highlight">reusable module</span> using <span class="highlight">for_each</span> over a map of environments.</p>
    <p>Infrastructure as code, authored from scratch.</p>
</body>
</html>

What does this file do?

This is the static HTML page your module will upload to S3. It is deliberately simple so you can focus on the Terraform code rather than web content.

The ${path.module} reference in the module will point to this file relative to the module directory, not the project root. That is what makes the module portable.

  • Save modules/s3-website/files/index.html.

File not saving?

Make sure you opened the terraform-s3-module folder (not a parent directory) in your editor. The file tree in the sidebar should show modules/ at the top level.

help me open my project folder correctly

✔️ Awesome, I've got everything!

Great. Make sure index.html is saved before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Terraform Module Site</title>
    <style>
        body {
            font-family: system-ui, -apple-system, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 2rem;
            background-color: #f0f4f8;
            color: #1a202c;
        }
        h1 { color: #2d3748; }
        .highlight { color: #3182ce; font-weight: bold; }
    </style>
</head>
<body>
    <h1>Hello from a Terraform Module!</h1>
    <p>This site was deployed by a <span class="highlight">reusable module</span> using <span class="highlight">for_each</span> over a map of environments.</p>
    <p>Infrastructure as code, authored from scratch.</p>
</body>
</html>
Write the provider configuration

The providers.tf file pins the minimum Terraform version and locks the AWS provider to a compatible range. This prevents breaking changes from sneaking in when teammates or CI systems run your code.

  • Open providers.tf (at the project root) and paste the following configuration:
terraform {
  required_version = ">= 1.10.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

  • required_version = ">= 1.10.0" ensures anyone running this project has at least Terraform 1.10.0.
  • source = "hashicorp/aws" tells Terraform to download the official AWS provider from the Terraform Registry.
  • version = "~> 6.0" allows any 6.x release but blocks a jump to 7.0 which could contain breaking changes.
  • region = var.aws_region reads the region from a variable you will define in the next step. Terraform will prompt for it until then.
  • Save providers.tf.

Seeing a syntax error in the editor?

Check that you have matching braces. The terraform {} block nests required_providers {} inside it, and aws = {} is nested one level deeper. Make sure every opening brace has a closing one.

help me fix my providers.tf syntax

✔️ Awesome, I've got everything!

Great. Make sure providers.tf is saved before continuing.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.10.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}
Run terraform init

Terraform needs to download the AWS provider binary before it can plan or apply any infrastructure. The terraform init command handles this and creates a lock file that pins the exact version.

  • In your terminal (still inside terraform-s3-module), initialize the project by running:
terraform init

You should see Terraform download the AWS provider and print Terraform has been successfully initialized! at the end of the output.

  • Confirm the provider was installed by running:
ls .terraform/providers/

You should see a registry.terraform.io directory. This is where Terraform stores the downloaded provider binary.

What did terraform init create?

Two new items appeared in your project. The .terraform/ directory holds the downloaded provider plugin. The .terraform.lock.hcl file records the exact provider version and checksums so every team member uses the same binary.

Seeing a provider download error?

Check your internet connection. Terraform downloads the provider from registry.terraform.io and needs network access.

If you see an error about var.aws_region being undeclared, that is expected. The variable does not exist yet. As long as the final line says Terraform has been successfully initialized! you are good to proceed.

help me fix my terraform init error

Your project skeleton is in place and the AWS provider is ready. Next up, you will define the module's interface using typed variables and validation rules.

Define the Module Interface with Typed Variables

Your project structure is in place and the AWS provider is ready.

Now you need to define the inputs your module accepts. A well-designed module interface tells callers exactly what data the module needs and rejects bad input before anything gets provisioned. You will use HCL's object type constraint and a validation block to build that interface.

In this step, get ready to:
  • Define a typed object variable that groups related module inputs together.
  • Add a validation rule that rejects invalid environment names at plan time.
  • Confirm your module files parse correctly with terraform validate.
Define the config object variable

Instead of passing each setting as a separate variable, you will group related fields into a single object variable. This keeps the module call clean and makes it clear which fields belong together.

  • Open modules/s3-website/variables.tf in your editor and add the following code:
variable "config" {
  type = object({
    project_name      = string
    environment       = string
    enable_versioning = bool
  })
  description = "Configuration object: project_name for naming, environment (dev/staging/prod), enable_versioning to toggle S3 versioning."
}

What does this code do?

The type = object({...}) constraint tells Terraform that callers must pass a value with exactly these fields and types. If someone passes a number where a string is expected, Terraform catches it immediately.

The three fields each serve a purpose: project_name will be used for resource naming, environment identifies the deployment target, and enable_versioning controls whether S3 versioning is on or off.

  • Save modules/s3-website/variables.tf.

Why group fields into an object?

Object variables are the production pattern for module inputs. They reduce the number of arguments a caller has to pass and make it obvious which settings are related. When you add fields later, you extend one variable instead of adding a new top-level argument.

Add a validation rule and the aws_region variable

Type constraints catch the wrong data type, but they cannot enforce allowed values. A validation block lets you reject bad input with a clear error message before Terraform provisions anything.

  • In modules/s3-website/variables.tf, find the closing } of the config variable.
  • Add a validation block inside the variable (before the final }) by pasting this code above the closing brace:
  validation {
    condition     = contains(["dev", "staging", "prod"], var.config.environment)
    error_message = "The environment must be one of: dev, staging, prod."
  }

What does this validation do?

The contains() function checks whether the environment field's value exists in the allowed list. If someone passes "test" or any other unlisted value, Terraform stops immediately and prints your custom error message.

Terraform evaluates variable validations during the plan phase. This means bad input never reaches the provider or creates half-built resources.

  • Below the config variable, add a second variable for the AWS region by pasting:
variable "aws_region" {
  type        = string
  description = "The AWS region where resources will be created. Used in bucket naming for global uniqueness."
}

Why no default on aws_region?

Omitting a default makes the variable required. The caller (your root module) must explicitly pass the region, which prevents accidental deployments to unexpected regions.

  • Save modules/s3-website/variables.tf.

Seeing a syntax error on save?

Make sure the validation block sits inside the config variable's braces, not after the closing }. The structure should be variable → type → description → validation → }.

Also check that aws_region is a separate variable block outside and below config, not nested inside it.

Help me fix a syntax error in my module variables.tf file.

✔️ Awesome, I've got everything!

Great. Double check you have saved modules/s3-website/variables.tf.

ⓧ I'd like to double check the full code

Here is the complete modules/s3-website/variables.tf file:

variable "config" {
  type = object({
    project_name      = string
    environment       = string
    enable_versioning = bool
  })
  description = "Configuration object: project_name for naming, environment (dev/staging/prod), enable_versioning to toggle S3 versioning."

  validation {
    condition     = contains(["dev", "staging", "prod"], var.config.environment)
    error_message = "The environment must be one of: dev, staging, prod."
  }
}

variable "aws_region" {
  type        = string
  description = "The AWS region where resources will be created. Used in bucket naming for global uniqueness."
}
Run terraform validate to confirm your syntax

Even though the root module does not call your module yet, running terraform validate from the root confirms that your overall configuration has no syntax errors.

  • In your terminal, make sure you are in the terraform-s3-module root folder and run:
terraform validate
  • You will see an error about the undeclared aws_region variable in the root configuration. This is expected because the root variables.tf is still empty while providers.tf references var.aws_region.

Why does this error appear?

Your root providers.tf uses var.aws_region to set the provider region. You have not declared that variable in the root variables.tf yet. You will add it in the next step when you wire up the root configuration.

The important thing is that Terraform did not report any HCL syntax errors. Your module's variables.tf is structurally valid.

Seeing a different error about your module files?

If the error mentions a syntax problem in modules/s3-website/variables.tf, check that your braces are balanced and that the validation block is nested inside the config variable. Use the "double check the full code" tab above to compare.

Help me fix a terraform validate error in my module.

Your module interface is locked down. Next up, you will write the actual resources that use these variables to provision S3 infrastructure.

Write the Module Resources with Locals and Conditionals

Your module interface is defined. The config variable knows what shape of data it expects, and the validation rule rejects bad input early.

Now it's time to write the resources that actually create infrastructure. Terraform modules rarely hardcode values directly into resources. Instead, they compute derived values in a locals block and feed those into resources. This keeps naming consistent, avoids repetition, and makes conditional logic readable.

In this step, get ready to:
  • Define a locals block that computes a bucket name, a boolean flag, and a shared tags map.
  • Write the S3 bucket and versioning resources using conditional expressions.
  • Add the remaining resources for website hosting, public access, and file upload.
Define the locals block

Locals let you compute values once and reference them throughout your resources. This avoids repeating the same string interpolation in multiple places.

  • In modules/s3-website/main.tf, add the following locals block:
locals {
  bucket_name = "${var.config.project_name}-${var.config.environment}-${var.aws_region}"

  is_production = var.config.environment == "prod"

  common_tags = {
    Name        = "${var.config.project_name}-${var.config.environment}"
    Environment = var.config.environment
    ManagedBy   = "terraform"
  }
}

What does this code do?

This locals block computes three values that your resources will use:

  • bucket_name uses string interpolation to combine the project name, environment, and region into a globally unique S3 bucket name.
  • is_production evaluates a boolean expression. It's true only when environment equals "prod".
  • common_tags is a map that every resource shares. Tags applied once in locals stay consistent across all resources.
  • Save modules/s3-website/main.tf.

You won't see output yet. The next chunk uses these locals in the actual S3 resources.

Write the S3 bucket and versioning resources

The bucket resource creates the S3 bucket itself, and the versioning resource controls whether file history is kept. The versioning status uses a conditional (ternary) expression to vary between environments.

  • Below the locals block in modules/s3-website/main.tf, add the following two resources:
resource "aws_s3_bucket" "website" {
  bucket = local.bucket_name
  tags   = local.common_tags
}

resource "aws_s3_bucket_versioning" "website" {
  bucket = aws_s3_bucket.website.id

  versioning_configuration {
    status = var.config.enable_versioning ? "Enabled" : "Suspended"
  }
}

What does the conditional expression do?

The ? syntax is Terraform's conditional (ternary) expression. It reads: if enable_versioning is true, set the status to "Enabled". Otherwise, set it to "Suspended".

This means production environments can keep versioning enabled for rollback safety, while dev environments skip the overhead.

  • Save modules/s3-website/main.tf.

You won't see changes yet. The remaining resources complete the module's functionality.

Seeing syntax highlighting errors?

Make sure strings using interpolation are wrapped in double quotes and use the ${} syntax. A missing quote or brace will break the entire file.

help me fix a syntax error in my Terraform locals or resource block

Add website configuration, public access, policy, and file upload

The remaining resources configure the bucket as a public static website. They enable website hosting, remove the default public access block, attach a bucket policy allowing public reads, and upload the index.html file.

  • Below the versioning resource in modules/s3-website/main.tf, add these four resources:
resource "aws_s3_bucket_website_configuration" "website" {
  bucket = aws_s3_bucket.website.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }
}

resource "aws_s3_bucket_public_access_block" "website" {
  bucket = aws_s3_bucket.website.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_policy" "website" {
  bucket = aws_s3_bucket.website.id

  depends_on = [aws_s3_bucket_public_access_block.website]

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "PublicReadGetObject"
        Effect    = "Allow"
        Principal = "*"
        Action    = "s3:GetObject"
        Resource  = "${aws_s3_bucket.website.arn}/*"
      }
    ]
  })
}

resource "aws_s3_object" "index" {
  bucket       = aws_s3_bucket.website.id
  key          = "index.html"
  source       = "${path.module}/files/index.html"
  content_type = "text/html"
  etag         = filemd5("${path.module}/files/index.html")
}

What do these resources do?

  • aws_s3_bucket_website_configuration tells S3 to serve index.html as the default page and error.html for errors.
  • aws_s3_bucket_public_access_block sets all four public-access settings to false. S3 blocks public access by default, so you must explicitly disable those guards for a public website.
  • aws_s3_bucket_policy uses jsonencode to create an IAM policy allowing anyone to read objects. The depends_on ensures the public access block is removed before applying the policy.
  • aws_s3_object uploads index.html using ${path.module} to reference the file relative to the module directory. The etag with filemd5() triggers a re-upload whenever the file content changes.
  • Save modules/s3-website/main.tf.
  • Confirm your file has no syntax issues by running:
terraform fmt modules/s3-website/main.tf

If the command prints the filename, it reformatted your file. If it prints nothing, your formatting already matches the canonical style. Either result means no syntax errors.

Seeing an error from terraform fmt?

The terraform fmt command reports invalid characters and invalid expressions. Check for missing closing braces, unmatched quotes, or a stray $ without {} around it.

help me fix my Terraform formatting error

✔️ Awesome, I've got everything!

Great. Double check you've saved modules/s3-website/main.tf and that terraform fmt runs without errors.

ⓧ I'd like to double check the full code

Here is the complete modules/s3-website/main.tf file after this step:

locals {
  bucket_name = "${var.config.project_name}-${var.config.environment}-${var.aws_region}"

  is_production = var.config.environment == "prod"

  common_tags = {
    Name        = "${var.config.project_name}-${var.config.environment}"
    Environment = var.config.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket" "website" {
  bucket = local.bucket_name
  tags   = local.common_tags
}

resource "aws_s3_bucket_versioning" "website" {
  bucket = aws_s3_bucket.website.id

  versioning_configuration {
    status = var.config.enable_versioning ? "Enabled" : "Suspended"
  }
}

resource "aws_s3_bucket_website_configuration" "website" {
  bucket = aws_s3_bucket.website.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }
}

resource "aws_s3_bucket_public_access_block" "website" {
  bucket = aws_s3_bucket.website.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_policy" "website" {
  bucket = aws_s3_bucket.website.id

  depends_on = [aws_s3_bucket_public_access_block.website]

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "PublicReadGetObject"
        Effect    = "Allow"
        Principal = "*"
        Action    = "s3:GetObject"
        Resource  = "${aws_s3_bucket.website.arn}/*"
      }
    ]
  })
}

resource "aws_s3_object" "index" {
  bucket       = aws_s3_bucket.website.id
  key          = "index.html"
  source       = "${path.module}/files/index.html"
  content_type = "text/html"
  etag         = filemd5("${path.module}/files/index.html")
}

Your module resources are complete. Every resource references the locals and variables you defined earlier. Next, you'll wire up the module outputs and connect the root configuration to deploy multiple environments from a single module call.

Add Module Outputs and Wire Up the Root

Your module can now create all the S3 resources it needs. But a module that does work without reporting results is like a function with no return value.

In this step, you will define outputs so the module exposes useful information to its caller. Then you will write the root configuration that actually invokes the module, using for_each to create both a dev and prod environment from a single module block.

In this step, get ready to:
  • Define module outputs that expose the website URL, bucket name, ARN, and production flag.
  • Write root variables with a typed map for multi-environment configuration.
  • Call the module from the root using for_each to deploy dev and prod from one block.
Define module outputs

Outputs are how a module communicates results back to its caller. Without them, the root configuration has no way to access the bucket URL or name that the module created.

  • Open modules/s3-website/outputs.tf and add the following output definitions:
output "website_url" {
  description = "The public URL of the static website"
  value       = "http://${aws_s3_bucket_website_configuration.website.website_endpoint}"
}

output "bucket_name" {
  description = "The name of the S3 bucket"
  value       = aws_s3_bucket.website.id
}

output "bucket_arn" {
  description = "The ARN of the S3 bucket"
  value       = aws_s3_bucket.website.arn
}

output "is_production" {
  description = "Whether this instance is a production deployment"
  value       = local.is_production
}

What does this code do?

Each output exposes a different piece of information from the module's internal resources:

  • website_url constructs the full public URL by interpolating the website endpoint attribute from the bucket configuration resource.
  • bucket_name and bucket_arn pull directly from the bucket resource's attributes.
  • is_production passes through the boolean you computed in locals. The caller can use this to make its own decisions.
  • Save modules/s3-website/outputs.tf.

You won't see a change yet. The next chunk wires up the root to call this module, which is when outputs become reachable.

Seeing a syntax error?

  • Make sure each output block has both a description and a value attribute. Missing either causes a parse error.
  • Check the string interpolation in website_url. The ${...} must be inside double quotes.
  • Confirm that local.is_production uses the singular local (not locals). The block is named locals but references use local.

Why isn't my output referencing the correct resource attribute?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

output "website_url" {
  description = "The public URL of the static website"
  value       = "http://${aws_s3_bucket_website_configuration.website.website_endpoint}"
}

output "bucket_name" {
  description = "The name of the S3 bucket"
  value       = aws_s3_bucket.website.id
}

output "bucket_arn" {
  description = "The ARN of the S3 bucket"
  value       = aws_s3_bucket.website.arn
}

output "is_production" {
  description = "Whether this instance is a production deployment"
  value       = local.is_production
}
Define root variables with a typed map

The root configuration needs to know which region to deploy into and what environments to create. Instead of duplicating module calls, you will define a single map(object({...})) variable that holds the configuration for every environment.

  • Open the root variables.tf (the one at the top level of your project, not inside modules/) and add the following:
variable "aws_region" {
  type        = string
  description = "The AWS region to deploy resources in"
  default     = "us-east-1"
}

variable "environments" {
  type = map(object({
    project_name      = string
    enable_versioning = bool
  }))
  description = "Map of environment names to their configuration. Each key becomes the environment name."
  default = {
    dev = {
      project_name      = "my-tf-site"
      enable_versioning = false
    }
    prod = {
      project_name      = "my-tf-site"
      enable_versioning = true
    }
  }
}

What does this code do?

  • aws_region defaults to us-east-1 but lets the caller override it. Your providers.tf already references var.aws_region, so this variable feeds both the provider and the module.
  • environments uses the map(object({...})) type. Each map key (like "dev" or "prod") becomes the environment name. The object value holds settings specific to that environment.
  • Notice that the object type here only has project_name and enable_versioning. The environment name itself comes from the map key, so it does not need to be inside the object.

Why a map instead of separate variables per environment?

A map of environments lets you add or remove environments by editing one variable instead of duplicating entire module blocks. This is the standard production pattern for multi-environment infrastructure with Terraform.

  • Save variables.tf.

You won't see a change yet. The next chunk uses this variable to call the module.

Getting a type error?

  • Make sure the type line uses map(object({...})) with parentheses and braces in the correct nesting order.
  • Confirm that each entry in the default block has exactly the fields declared in the type: project_name (string) and enable_versioning (bool). Extra or missing fields cause a mismatch.

Help me debug my map(object) variable type definition.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

variable "aws_region" {
  type        = string
  description = "The AWS region to deploy resources in"
  default     = "us-east-1"
}

variable "environments" {
  type = map(object({
    project_name      = string
    enable_versioning = bool
  }))
  description = "Map of environment names to their configuration. Each key becomes the environment name."
  default = {
    dev = {
      project_name      = "my-tf-site"
      enable_versioning = false
    }
    prod = {
      project_name      = "my-tf-site"
      enable_versioning = true
    }
  }
}
Call the module from root main.tf

This is where everything connects. A single module block with for_each iterates over the environments map and creates a separate instance of your module for each entry.

  • Open the root main.tf and add the module call:
module "website" {
  source   = "./modules/s3-website"
  for_each = var.environments

  config = {
    project_name      = each.value.project_name
    environment       = each.key
    enable_versioning = each.value.enable_versioning
  }

  aws_region = var.aws_region
}

What does this code do?

  • source points Terraform at your local module directory.
  • for_each = var.environments tells Terraform to create one module instance per map entry. With two entries (dev and prod), Terraform creates module.website["dev"] and module.website["prod"].
  • each.key gives you the map key ("dev" or "prod"), which becomes the environment field in the config object.
  • each.value gives you the object at that key, so each.value.project_name pulls the project name from the current entry.
  • Save main.tf.
  • Verify the configuration parses correctly by running:
terraform validate

You should see Success! The configuration is valid. in the output.

Seeing an error about module source?

  • If you see an error about the module not being installed, run terraform init first. Terraform needs to discover local modules before it can validate them.
  • Check that source is set to ./modules/s3-website with a leading ./. Without it, Terraform looks for a registry module instead of a local path.
  • Confirm your directory structure matches: the modules/s3-website/ folder should contain main.tf, variables.tf, and outputs.tf.

Help me debug my module source path or for_each configuration.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

module "website" {
  source   = "./modules/s3-website"
  for_each = var.environments

  config = {
    project_name      = each.value.project_name
    environment       = each.key
    enable_versioning = each.value.enable_versioning
  }

  aws_region = var.aws_region
}

Your module is now fully wired up. The root configuration can create both environments from a single block of code. Next up, you will run terraform plan to see all the resources Terraform will create, then deploy both websites live.

Deploy, Verify, and Explore Outputs

Your module is wired up, your root configuration is calling it with for_each, and all the files are ready. Now it is time to see Terraform bring both environments to life from a single module block.

You will run the plan to preview what Terraform creates, apply to provision real AWS resources, then write root-level outputs using for expressions. These outputs give you a single, clean map of every environment's URL.

In this step, get ready to:
  • Run terraform plan and read the keyed module instances.
  • Provision both environments with terraform apply and verify live websites.
  • Write root outputs using for expressions and register them.
Run terraform plan and read the output

Before Terraform creates any real resources, it produces a plan showing exactly what it intends to do. Because your module block uses for_each, the plan reveals how Terraform addresses each environment as a keyed instance.

  • Preview the infrastructure Terraform will create by running this command:
terraform plan

What should I see?

The plan output shows resource addresses like module.website["dev"].aws_s3_bucket.website and module.website["prod"].aws_s3_bucket.website. The map key ("dev" or "prod") is in square brackets after the module name.

Count the total resources at the bottom of the plan. You should see 12 resources to add (6 per environment: bucket, versioning, website configuration, public access block, policy, and the index.html object).

Why are there separate instances?

When you use for_each on a module block, Terraform creates a completely independent copy of the module's resources for each map key. The key becomes a stable identifier in the state file. If you later remove the "dev" entry from your map, Terraform destroys only the dev resources without touching prod.

Seeing an error instead of a plan?

  • If you see a provider authentication error, confirm your AWS CLI is configured by running aws sts get-caller-identity in your terminal.
  • If Terraform says it cannot find the module, make sure you ran terraform init from the root terraform-s3-module directory.

Help me debug my terraform plan error

Apply and verify both websites are live

The plan looks good. Time to provision real S3 buckets and confirm you can visit both websites in a browser.

  • Provision both environments by running this command:
terraform apply
  • When Terraform prompts you to confirm, type yes and press Enter.

What should I see?

Terraform creates 12 resources across both environments. At the end, you see Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

Look in the apply output for the website_endpoint attribute on each aws_s3_bucket_website_configuration resource. These are the public URLs for your static websites.

  • Find the two website_endpoint values in the apply output. They follow the pattern my-tf-site-dev-us-east-1.s3-website-us-east-1.amazonaws.com.
  • Open both URLs in your browser (add http:// before the endpoint). You should see the "Hello from a Terraform Module!" page on both.

Both websites are live. However, digging through apply output to find URLs is not a great experience. Next you will define proper root outputs so you can retrieve the URLs with a single command.

Seeing a 403 Forbidden error in the browser?

  • Make sure you are using the S3 website endpoint URL (contains s3-website), not the standard S3 bucket URL.
  • If the bucket policy failed to apply, check that aws_s3_bucket_public_access_block has all four settings set to false in your module's main.tf.

Help me fix a 403 error on my S3 static website

Define root outputs with for expressions

Root outputs expose information from your deployed infrastructure so you can retrieve it without reading raw apply logs. A for expression lets you transform the module's keyed instances into a clean map.

  • Open outputs.tf in the root of your terraform-s3-module folder.
  • Add the following output blocks:
output "website_urls" {
  description = "Map of environment names to their website URLs"
  value       = { for k, v in module.website : k => v.website_url }
}

output "bucket_names" {
  description = "Map of environment names to their S3 bucket names"
  value       = { for k, v in module.website : k => v.bucket_name }
}

What does this code do?

  • The for expression { for k, v in module.website : k => v.website_url } iterates over every keyed instance of the module.
  • For each iteration, k is the map key ("dev" or "prod") and v is the module instance with all its outputs.
  • The result is a map like { dev = "http://...", prod = "http://..." } instead of separate outputs per environment.
  • Save outputs.tf.
  • Register the new outputs by running this command:
terraform apply
  • Type yes when prompted to confirm.
  • After apply completes, retrieve your outputs by running:
terraform output
  • You should see a clean map for each output, with environment names as keys and their URLs or bucket names as values.

Why define root outputs?

Module outputs are only accessible to the calling configuration. Root outputs make values available to the CLI (terraform output), to other Terraform configurations via remote state, and to CI/CD pipelines that parse Terraform's JSON output. They are the public API of your root module.

Outputs showing empty or not appearing?

  • Make sure you saved outputs.tf before running terraform apply.
  • Check that the output blocks reference module.website (the exact name from your module block in main.tf).

Help me fix my terraform output

✔️ Awesome, I've got everything!

Great. Double check you have saved outputs.tf and that terraform output shows both website_urls and bucket_names maps.

ⓧ I'd like to double check the full code

Here is the complete outputs.tf file for comparison:

output "website_urls" {
  description = "Map of environment names to their website URLs"
  value       = { for k, v in module.website : k => v.website_url }
}

output "bucket_names" {
  description = "Map of environment names to their S3 bucket names"
  value       = { for k, v in module.website : k => v.bucket_name }
}

You now have two live S3 websites deployed from a single module, and clean outputs that summarize everything in one place. Next up, you will stress-test the module's validation rule and format your code before tearing it all down.

Test Validation and Destroy

Both of your environments are live and serving real web pages. Your module works. But how do you know the guardrails you built actually catch mistakes?

In this step, you will intentionally break your configuration to prove that the validation rule catches bad input. Then you will format, validate, and tear down all resources cleanly.

In this step, get ready to:
  • Test your validation rule by passing an invalid environment value.
  • Format and validate the full project.
  • Destroy all AWS resources from both module instances.
Test your validation rule

The validation block you wrote in your module enforces that the environment value is one of dev, staging, or prod. Time to prove it works by deliberately breaking it.

  • In your root variables.tf, find the environments variable's default map.
  • Add a third entry called test after the prod block. Copy the following code and paste it after the closing brace of the prod entry:
    test = {
      project_name      = "my-tf-site"
      enable_versioning = false
    }

What does this do?

The key test becomes the environment value passed to your module (via each.key). Since test is not in the allowed list ["dev", "staging", "prod"], the validation block should reject it before Terraform creates any resources.

  • Save variables.tf.
  • Run terraform plan to trigger the validation check:
terraform plan

Terraform rejects the plan and prints your custom error message in the terminal output.

What should I see?

You should see an error that includes the text: The environment must be one of: dev, staging, prod. This is the exact error_message string you wrote in your module's validation block.

The plan fails immediately. No resources are created, modified, or destroyed. This is exactly what you want from input validation.

  • Now remove the entire test block you just added from variables.tf.
  • Save variables.tf.

Not seeing a validation error?

Make sure the test key is placed inside the default map block (between the outer braces), not outside it. If you see a syntax error instead of a validation error, check for missing commas or mismatched braces.

Help me debug my Terraform validation error

Format and validate your configuration

Before tearing down the infrastructure, confirm that every file in the project follows Terraform's canonical formatting and that no syntax errors have crept in.

  • Format all files (root and nested module) by running this command:
terraform fmt -recursive

Why -recursive?

By default, terraform fmt only formats files in the current directory. The -recursive flag tells it to also format files in subdirectories like modules/s3-website/.

  • Confirm the configuration has no errors by running:
terraform validate

You should see the message Success! The configuration is valid. in your terminal.

What does your module structure look like?

Your project now follows HashiCorp's standard module structure. The root has providers.tf, main.tf, variables.tf, and outputs.tf. The nested module under modules/s3-website/ has its own main.tf, variables.tf, and outputs.tf.

This separation keeps modules portable. Anyone can drop your modules/s3-website/ folder into their own project and call it with their own inputs.

Seeing formatting changes or validation errors?

If terraform fmt prints file names, it means those files were reformatted. This is normal. If terraform validate fails, check that you fully removed the temporary test entry from the previous substep.

Help me fix my Terraform validate error

Destroy both environments

Your module is tested and your configuration is clean. Time to tear down the AWS resources so your account stays tidy.

  • Remove all resources from both environments by running:
terraform destroy

What happens during destroy?

Terraform shows a destroy plan listing every resource it will remove. Because your module uses for_each, you will see resources grouped under module.website["dev"] and module.website["prod"].

Each module instance created 6 resources (bucket, versioning, website config, public access block, policy, and object). So the destroy plan should show 12 total resources to destroy.

  • Terraform asks for confirmation. Type yes and press Enter to proceed.
  • Wait for the destroy to complete. You should see a message confirming that all 12 resources were destroyed.

Destroy failing or timing out?

S3 bucket deletion can fail if objects were added outside of Terraform. If you see a BucketNotEmpty error, manually empty the bucket in the AWS Console first, then re-run terraform destroy.

Help me fix my terraform destroy error

Secret mission

Dynamic Blocks and For Expressions

Your module works, but it treats every environment identically. What if prod needs CORS headers for a frontend app hitting the S3 origin directly? Add a dynamic block that conditionally generates CORS rules only when the caller provides them, and build a rich summary output using an advanced for expression.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project uses AWS S3 within the free tier, so there are no ongoing costs if your buckets are destroyed. If you already ran terraform destroy in Step 6 or the Secret Mission, your AWS resources are already cleaned up.

Resources you used:

  • AWS S3 buckets (dev and prod) provisioned by your Terraform module.
  • Local project files in the terraform-s3-module folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to reference your module code later.

  • Your local project files stay in terraform-s3-module on your Desktop.
  • If your S3 buckets are still deployed, they cost virtually nothing within AWS free tier limits (5GB storage, 20,000 GET requests, 2,000 PUT requests per month for 12 months).
  • Your Terraform CLI and AWS CLI remain installed for future projects.

Pause - I'll come back to this later

Destroy the AWS resources to keep your account clean, but keep your local project files for future reference.

  • If you did not already run terraform destroy in Step 6 or the Secret Mission, run it now from your terraform-s3-module directory:
cd ~/Desktop/terraform-s3-module
terraform destroy
  • Type yes when prompted to confirm the destruction.
  • Your local files remain intact. You can re-deploy anytime by running terraform apply again.

Delete - I don't want to use this again

Remove all project resources and local files to start completely fresh.

  • If you did not already run terraform destroy, destroy your AWS resources first:
cd ~/Desktop/terraform-s3-module
terraform destroy
  • Type yes when prompted to confirm.
  • Delete the local project folder entirely. On macOS or Linux:
cd ~/Desktop
rm -rf terraform-s3-module
  • On Windows:
cd ~\Desktop
rmdir /s /q terraform-s3-module
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see terraform-s3-module in the output.

Nice Work!

Nice Work!

You just authored a production-quality reusable Terraform module from scratch. This module provisions multi-environment S3 static websites using the same professional HCL patterns that cloud engineers write daily.

You've learned how to:

  • Written typed object variables with validation rules that catch bad input before Terraform even creates a plan.
  • Used locals and conditional expressions to compute values, enforce naming conventions, and vary behavior between environments.
  • Deployed multiple environments from a single module block using for_each over a map of configurations, and produced clean outputs with for expressions.
  • Implemented dynamic blocks for optional CORS configuration and advanced for expression outputs in the Secret Mission.

Ready to quiz yourself?