AWS June 20, 2026 · 🕐 8 min read

Hosting a Static Website on S3 + CloudFront with Terraform

A complete walkthrough of setting up a production-ready static site using S3, CloudFront OAI, ACM, and Route 53, all managed with Terraform.

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.

Back to Blog
AWS S3 CloudFront Terraform