DEV Community

Anil KUMAR
Anil KUMAR

Posted on

Day 14— AWS Terraform Static Website Hosting

This blog will mark the Day 14 of 30 days of AWS Terraform Challenge Initiative by Piyush Sachdeva. In this blog, we will be doing a hands-on exercise of hosting a Static website on AWS S3 and accessing that through CloudFront Distribution.

This mini project demonstrates how to deploy a static website on AWS using Terraform. We'll create a complete static website hosting solution using S3 for storage and CloudFront for global content delivery.

Architecture:

Internet → CloudFront Distribution → S3 Bucket (Static Website)
Enter fullscreen mode Exit fullscreen mode

Components:

S3 Bucket: Hosts static website files (HTML, CSS, JS)
CloudFront Distribution: Global CDN for fast content delivery
Public Access Configuration: Allows public reading of website files.

Workflow:

  • Prepare your static website files: Create your HTML, CSS, JavaScript, and image files.
  • Write Terraform configuration: Define the AWS resources (S3 bucket, CloudFront, Route 53) in .tf files.
  • Initialize Terraform: Run terraform init to download necessary providers.
  • Plan changes: Run terraform plan to see what changes will be applied.
  • Apply changes: Run terraform apply to provision the resources in AWS.
  • Upload content: If not using Terraform to upload objects, upload your static files to the S3 bucket.
  • Access your website: Access your website using the S3 static website endpoint or your custom domain if configured.

CloudFront with S3 — Why It is Needed and How It Works:

Hosting a static website directly on Amazon S3 is simple and cost-effective, but it has significant limitations when serving real users at scale.

Problems with S3-only Hosting:

  • High latency for global users:
    S3 buckets exist in a single AWS region. Users located far from that region experience slower load times because every request must travel long physical distances to reach the S3 data center.

  • Higher data-transfer costs
    When traffic grows, serving all content from one region increases inter-region data transfer costs. S3 also does not cache responses, which means every request hits the origin, increasing overall expenses.

  • Security challenges
    Traditional S3 static hosting requires making the bucket public, exposing your content directly to the internet. This introduces risks such as:

  • Direct object access outside your website

  • Potential misuse of public URLs

  • Difficulty enforcing fine-grained access control

Amazon CloudFront, AWS’s global CDN, solves these issues by acting as a secure, fast, distributed caching layer in front of your S3 bucket.

Key Benefits

  • Global low-latency delivery
    CloudFront uses edge locations worldwide to cache content close to users.
    First request: Served from S3
    Subsequent requests: Served from nearest CloudFront edge
    This dramatically reduces page load time for users anywhere in the world.

  • Lower cost with caching
    Since CloudFront serves most requests from its cache, fewer requests reach S3. This reduces S3 data-transfer and request costs. CloudFront’s global data transfer rates are also cheaper than S3’s regional egress charges.

  • Enhanced security with Origin Access Control (OAC)
    Modern deployments use OAC, which includes:

  • Gives CloudFront permission to read from your private S3 bucket

  • Removes the need for ANY public bucket permissions

  • Ensures users can ONLY access S3 content through CloudFront

Complete Architecture Diagram

Key Components:

  1. Private S3 Bucket with static files (HTML, CSS, JS, images).
  2. Origin Access Control (OAC) - The modern, secure way to authorize CloudFront to access the private S3 bucket. This replaces the deprecated Origin Access Identity (OAI).
  3. S3 Bucket Policy - Explicitly authorizes the CloudFront Distribution's service principal and restricts access using the OAC condition.
  4. CloudFront Distribution - Caches content and serves it globally over HTTPS.

Terraform Code:

1. Setting the static files and variables.tf

mkdir www
# Add static files: index.html, style.css, script.js
Enter fullscreen mode Exit fullscreen mode
variable "bucket_name" {
  default = "my-static-website-bucket-anilkumar"
}

locals {
  origin_id = "s3-static-site-origin"
}
Enter fullscreen mode Exit fullscreen mode

2. Creating S3 with public access blocked

The bucket is defined as a standard S3 bucket, and then an access block resource is used to explicitly block all public access, ensuring it can only be accessed by CloudFront.

// first we create s3 resource 
resource "aws_s3_bucket" "my_first_bucket" {
  bucket = var.bucket_name
}

// here we make the bucket private
resource "aws_s3_bucket_public_access_block" "example" {
  bucket = aws_s3_bucket.my_first_bucket.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
Enter fullscreen mode Exit fullscreen mode

3. Origin Access Control (OAC)

This resource creates the secure identity that CloudFront will use when communicating with S3.

// now we allow the origin to access the bucket
resource "aws_cloudfront_origin_access_control" "my_origin_access_control" {
  name                              = "my_origin_access_control"
  description                       = "OAC for S3 Static Site"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}
Enter fullscreen mode Exit fullscreen mode

Purpose: Secure identity for CloudFront S3 communication, using the modern SigV4 signing protocol.

4. S3 Bucket Policy (Important):

This policy is the crucial authorization step. It only allows s3:GetObject (read-access for files) and only from the CloudFront service, specifically the ARN of our distribution using the AWS:SourceArn condition.

// bucket policy
resource "aws_s3_bucket_policy" "allow_access_from_cloudfront" {
  bucket = aws_s3_bucket.my_first_bucket.id

  depends_on = [aws_s3_bucket_public_access_block.example]

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCloudFrontS3Access"
        Effect = "Allow"

        # FIXED → Correct Principal for CloudFront Service
        Principal = {
          Service = "cloudfront.amazonaws.com"
        }

        Action = [
          "s3:GetObject",
        ]

        Resource = [
          "${aws_s3_bucket.my_first_bucket.arn}/*"
        ]

        # FIXED → Required OAC condition to ensure only THIS distribution can access
        Condition = {
          StringEquals = {
            "AWS:SourceArn" = aws_cloudfront_distribution.s3_distribution.arn
          }
        }
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

5. Uploading Static files:

This resource uses the fileset function to iterate over all files in the local www directory and uploads them to the S3 bucket with the correct MIME type (Content-Type) based on the file extension.

// s3 bucket object 
resource "aws_s3_object" "object" {

  bucket   = aws_s3_bucket.my_first_bucket.id
  for_each = fileset("${path.module}/www", "**/*")
  key      = each.value
  source   = "${path.module}/www/${each.value}"

  etag = filemd5("${path.module}/www/${each.value}")
  content_type = lookup({
    # Common MIME types. Simplified lookup logic using `regex` in the previous blog, but this is clear.
    "html"         = "text/html"
    "css"          = "text/css"
    "js"           = "application/javascript"
    "jpeg"         = "image/jpeg"
    "png"          = "image/png"
    "gif"          = "image/gif"
    "jpg"          = "image/jpg"
    # ... other types
  }, split(".", each.value)[length(split(".", each.value)) - 1], "application/octet-stream")
}
Enter fullscreen mode Exit fullscreen mode

6. CloudFront Distribution

This is the main resource that ties everything together. It defines the caching rules, specifies the S3 bucket as the origin, and links the OAC for secure access.

resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    # Use bucket_regional_domain_name for private S3 origin with OAC/OAI
    domain_name              = aws_s3_bucket.my_first_bucket.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.my_origin_access_control.id
    origin_id                = local.origin_id
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = "CloudFront distribution for static site"
  default_root_object = "index.html" # Important for serving the index file

  default_cache_behavior {
    allowed_methods    = ["GET", "HEAD"]
    cached_methods     = ["GET", "HEAD"]
    target_origin_id   = local.origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none" # Static sites don't need cookies forwarded
      }
    }

    # IMPROVED: Changed from "allow-all" to "redirect-to-https" for security best practice
    viewer_protocol_policy = "redirect-to-https" 
    min_ttl                = 0
    default_ttl            = 3600 # 1 hour default cache
    max_ttl                = 86400 # 24 hours max cache
  }

  price_class = "PriceClass_100" # Lowest cost, basic global coverage

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    # Use AWS's default certificate for HTTPS
    cloudfront_default_certificate = true 
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Outputs Declaration:

output "website_url" {
  description = "The URL of the static website"
  value       = "https://${aws_cloudfront_distribution.s3_distribution.domain_name}"
}

output "cloudfront_distribution_id" {
  description = "The ID of the CloudFront distribution"
  value       = aws_cloudfront_distribution.s3_distribution.id
}

output "s3_bucket_name" {
  description = "The name of the S3 bucket"
  value       = aws_s3_bucket.website.bucket
}
Enter fullscreen mode Exit fullscreen mode

8. Deploy & Test

terraform init
terraform plan
terraform apply --auto-approve
Enter fullscreen mode Exit fullscreen mode
terraform apply 


Plan: 8 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + cloudfront_distribution_id = (known after apply)
  + s3_bucket_name             = (known after apply)
  + website_url                = (known after apply)
aws_cloudfront_origin_access_control.oac: Creating...
aws_s3_bucket.website: Creating...
aws_cloudfront_origin_access_control.oac: Creation complete after 2s [id=E1KGAVQXOCS06C]
aws_s3_bucket.website: Creation complete after 7s [id=my-static-website-anilkumar20251209102509049200000001]
aws_s3_bucket_public_access_block.website: Creating...
aws_s3_object.website_files["style.css"]: Creating...
aws_s3_object.website_files["script.js"]: Creating...
aws_s3_object.website_files["index.html"]: Creating...
aws_cloudfront_distribution.s3_distribution: Creating...
aws_s3_bucket_public_access_block.website: Creation complete after 1s [id=my-static-website-anilkumar20251209102509049200000001]
aws_s3_object.website_files["script.js"]: Creation complete after 1s [id=script.js]
aws_s3_object.website_files["style.css"]: Creation complete after 1s [id=style.css]
aws_s3_object.website_files["index.html"]: Creation complete after 1s [id=index.html]
aws_cloudfront_distribution.s3_distribution: Still creating... [10s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [20s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [30s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [40s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [50s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [1m0s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [1m10s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [1m20s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [1m30s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [1m40s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [1m50s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [2m0s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [2m10s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [2m20s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [2m30s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [2m40s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [2m50s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [3m0s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [3m10s elapsed]
aws_cloudfront_distribution.s3_distribution: Still creating... [3m20s elapsed]
aws_cloudfront_distribution.s3_distribution: Creation complete after 3m27s [id=E3PI31IGA77HJ]
aws_s3_bucket_policy.website: Creating...
aws_s3_bucket_policy.website: Creation complete after 5s [id=my-static-website-anilkumar20251209102509049200000001]

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

Outputs:

cloudfront_distribution_id = "E3PI31IGA77HJ"
s3_bucket_name = "my-static-website-anilkumar20251209102509049200000001"
website_url = "https://d2u2vdb3lrnvju.cloudfront.net"
Enter fullscreen mode Exit fullscreen mode

Here the outputs of S3 bucket creation and CloudFront Distribution creation

You could see the creation of S3 bucket with the bucket-prefix we set.

You could see the creation of CloudFront Distribution.

You could see that we are serving the static website through S3 bucket hosting.

Imp Tip: Always pin provider versions in your configurations to prevent unexpected changes in resource behavior.

Conclusion:

This concludes the Day 14 of 30 days of terraform challenge. Today we have done a mini project on Static Website hosting through AWS S3 and CloudFront Distribution. See you tomorrow in a new blog

Top comments (0)