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 Origin Access Identity (OAI) pattern for secure CloudFront to S3 access.
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 a canonical user principal.
- 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 a proven way to give CloudFront private access to S3. It works by:
- Creating a special AWS account identity that only CloudFront can use.
- Using an S3 bucket policy to allow only that identity to read objects.
- Preventing any direct access to the S3 bucket URL, enforcing all traffic through CloudFront.
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_identity" "website" {
comment = "OAI for ${var.bucket_name}"
}
resource "aws_cloudfront_distribution" "website" {
origin {
domain_name = aws_s3_bucket.website.bucket_regional_domain_name
origin_id = "S3-${var.bucket_name}"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.website.cloudfront_access_identity_path
}
}
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 Work section of this site, or view it on GitHub.