DEV Community

Mukami
Mukami

Posted on

Deploying a Static Website on AWS S3 with Terraform: A Beginner's Guide

From Zero to a Live, Globally Distributed Website in One Day


Day 25 of the 30-Day Terraform Challenge — and today I built something I can actually show people.

Not just infrastructure. Not just a cluster that responds to curl. An actual website. With a URL. That works in a browser.

Using nothing but Terraform code.


What I Built

A fully serverless static website hosted on AWS S3, served globally through CloudFront, with HTTPS, custom error pages, and environment isolation.

All deployed with one command: terraform apply


The Architecture

User → CloudFront (HTTPS) → S3 Bucket (Static Files)
                              ├── index.html
                              └── error.html
Enter fullscreen mode Exit fullscreen mode
  • S3 bucket stores the HTML files
  • Bucket policy makes the files publicly readable
  • CloudFront distributes content globally and adds HTTPS
  • Terraform manages everything as code

The Project Structure

day25-static-website/
├── modules/
│   └── s3-static-website/
│       ├── main.tf          # S3 bucket + CloudFront
│       ├── variables.tf     # Configurable inputs
│       └── outputs.tf       # CloudFront URL
├── envs/
│   └── dev/
│       ├── main.tf          # Module call
│       ├── variables.tf
│       └── terraform.tfvars # Environment config
├── backend.tf               # Remote state
└── provider.tf
Enter fullscreen mode Exit fullscreen mode

This structure enforces DRY — the module is reusable, and the environment configuration is minimal.


The Module (Reusable)

The module encapsulates everything needed for a static website:

# modules/s3-static-website/main.tf

resource "aws_s3_bucket" "website" {
  bucket = var.bucket_name
  force_destroy = var.environment != "production"
}

resource "aws_s3_bucket_website_configuration" "website" {
  bucket = aws_s3_bucket.website.id
  index_document { suffix = var.index_document }
  error_document { key = var.error_document }
}

resource "aws_cloudfront_distribution" "website" {
  enabled = true
  origin {
    domain_name = aws_s3_bucket_website_configuration.website.website_endpoint
    origin_id   = "s3-website"
  }
  default_cache_behavior {
    viewer_protocol_policy = "redirect-to-https"
  }
}
Enter fullscreen mode Exit fullscreen mode

Key design decisions:

  • force_destroy = var.environment != "production" — dev buckets can be destroyed easily, production buckets are protected
  • CloudFront adds HTTPS automatically — no certificate needed for the default domain
  • Module outputs the CloudFront URL so callers can access it

The Environment Configuration (Clean)

Because the module does all the heavy lifting, the dev environment config is tiny:

# envs/dev/main.tf
module "static_website" {
  source = "../../modules/s3-static-website"

  bucket_name    = var.bucket_name
  environment    = var.environment
  index_document = "index.html"
  error_document = "error.html"
}

output "cloudfront_url" {
  value = "https://${module.static_website.cloudfront_domain_name}"
}
Enter fullscreen mode Exit fullscreen mode
# envs/dev/terraform.tfvars
bucket_name = "my-terraform-website-day25-20260416"
environment = "dev"
Enter fullscreen mode Exit fullscreen mode

That's it. All the complexity lives in the module.


The Deployment

cd envs/dev
terraform init
terraform plan
terraform apply -auto-approve

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:
cloudfront_url = "https://d123.cloudfront.net"
Enter fullscreen mode Exit fullscreen mode

The Result

After waiting 10 minutes for CloudFront to propagate:

curl https://d123.cloudfront.net
Enter fullscreen mode Exit fullscreen mode
<!DOCTYPE html>
<html>
<head><title>Terraform Static Website</title></head>
<body>
  <h1>🚀 Day 25: Deployed with Terraform!</h1>
  <p>Environment: dev</p>
  <p>This website was deployed using Terraform on Day 25!</p>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

A live, globally distributed website. Deployed entirely through code.


Why This Project Matters

It's real. Not a demo. Not a "hello world" that only works on localhost. A real website with a real URL.

It's complete. S3 + CloudFront + HTTPS + error handling + environment isolation.

It's reusable. The module can deploy dev, staging, and production with different variables.

It demonstrates everything from Days 1-24:

  • Modules (Day 8-9)
  • Remote state (Day 6)
  • DRY principle (Day 4)
  • Environment isolation (Day 7)
  • Tags and best practices (Day 16)

What I Learned

S3 static websites are simple but have limits. No HTTPS on the S3 endpoint — that's why you need CloudFront.

CloudFront takes time. 5-15 minutes to propagate globally. Be patient.

The module pattern is powerful. I can now deploy a static website in any environment with 5 lines of code.

Remote state protects your work. The state file is encrypted in S3, not on my laptop.


The DRY Principle in Practice

Without a module, I would have written 100+ lines of S3 + CloudFront configuration for every environment. With a module, each environment needs only 5 lines.

That's the difference between a one-off script and production-grade infrastructure.


Top comments (0)