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)
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:
- Private S3 Bucket with static files (HTML, CSS, JS, images).
- 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).
- S3 Bucket Policy - Explicitly authorizes the CloudFront Distribution's service principal and restricts access using the OAC condition.
- 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
variable "bucket_name" {
default = "my-static-website-bucket-anilkumar"
}
locals {
origin_id = "s3-static-site-origin"
}
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
}
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"
}
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
}
}
}
]
})
}
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")
}
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
}
}
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
}
8. Deploy & Test
terraform init
terraform plan
terraform apply --auto-approve
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"
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)