Introduction
Hosting a static website on Amazon S3 fronted by CloudFront is one of the most cost-effective and scalable patterns in AWS. With S3's 99.999999999% (11 nines) durability and CloudFront's global edge network, you get enterprise-grade reliability for pennies.
In this post I'll walk you through the exact Terraform code I used to build this very portfolio website, including the newer Origin Access Control (OAC) โ the recommended replacement for the legacy OAI pattern.
Architecture Overview
The architecture is simple but production-grade:
- S3 Bucket โ stores all static assets (HTML, CSS, JS, images). Public access is completely blocked.
- CloudFront OAI โ authorises CloudFront to read from the private S3 bucket via SigV4-signed requests.
- CloudFront Distribution โ serves files from edge locations, handles HTTPS, caching, and custom error pages.
- ACM Certificate โ free TLS certificate provisioned in us-east-1 (required by CloudFront).
- Route 53 โ DNS alias record pointing to the CloudFront distribution.
Why I Used OAI?
Origin Access Identity (OAI) is the legacy way to give CloudFront private access to S3. AWS now recommends Origin Access Control (OAC) because it:
- Supports SigV4 request signing (more secure).
- Works with SSE-KMS encrypted buckets.
- Supports all S3 regions including the newer opt-in regions.
The Terraform Code
Let's look at the key resources. First, the S3 bucket with all public access blocked:
resource "aws_s3_bucket" "website" {
bucket = var.bucket_name
force_destroy = true
tags = {
Name = var.bucket_name
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Next, the CloudFront OAI and distribution:
resource "aws_cloudfront_origin_access_control" "website" {
name = "${var.bucket_name}-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "website" {
origin {
domain_name = aws_s3_bucket.website.bucket_regional_domain_name
origin_id = "S3-${var.bucket_name}"
origin_access_control_id = aws_cloudfront_origin_access_control.website.id
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
# ... cache behaviours, custom error pages, etc.
}
Custom Error Pages
CloudFront lets you configure custom error responses. This is how the error.html page gets served for 404s:
custom_error_response {
error_code = 404
response_code = 404
response_page_path = "/error.html"
error_caching_min_ttl = 10
}
custom_error_response {
error_code = 403
response_code = 404
response_page_path = "/error.html"
error_caching_min_ttl = 10
}
Note: S3 returns 403 (not 404) when a file doesn't exist and the bucket is private. Mapping 403 โ 404 in CloudFront gives users the proper error page.
Deploying the Website Files
After the infrastructure is provisioned, use the aws_s3_object resource or the AWS CLI to upload your files:
aws s3 sync ./website s3://your-bucket-name \
--cache-control "max-age=86400" \
--delete
# Invalidate CloudFront cache
aws cloudfront create-invalidation \
--distribution-id YOUR_DIST_ID \
--paths "/*"
Conclusion
With about 100 lines of Terraform you get a globally distributed, HTTPS-enforced, highly available static website. The S3 + CloudFront combo is unbeatable for static content hosting on AWS.
Check out the full Terraform source code in the Projects section of this site, or view it on GitHub.