CloudFront Distribution with S3 Origin and Origin Access Control
Scenario
Your team has built a React single-page application and wants to host it as a static site served globally via CDN. The S3 bucket must not be publicly accessible — only CloudFront should be able to read from it, using the modern Origin Access Control (OAC) (the successor to Origin Access Identity).
- Create a private S3 bucket (all public access blocked) and upload a placeholder index.html.
- Create an aws_cloudfront_origin_access_control (OAC).
- Attach a bucket policy that allows only CloudFront (with the OAC) to read objects.
- Create an aws_cloudfront_distribution with HTTPS-only viewer protocol, compression enabled, and a custom error response for 403 → index.html (for SPA routing).
- Output the CloudFront domain name.
Additional Context
OAC vs OAI: Origin Access Identity (OAI) is the older method and requires the S3 bucket to use ACLs. Origin Access Control (OAC) works with bucket policies, is more secure, and is the current AWS recommendation. OAC also supports SSE-KMS encrypted buckets — OAI does not.
The custom error page rule (403 → index.html with 200) is the standard trick for React/Vue/Angular SPAs hosted on S3 — S3 returns 403 for unknown paths, and CloudFront rewrites it to serve the SPA entry point, letting the JS router handle navigation.
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "bucket_name" { type = string; default = "my-spa-static-assets" }
variable "environment" { type = string; default = "prod" }main.tf
# main.tf
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
# Private S3 bucket — no public access allowed.
resource "aws_s3_bucket" "spa" {
bucket = "${var.bucket_name}-${data.aws_caller_identity.current.account_id}"
tags = { Environment = var.environment, ManagedBy = "terraform" }
}
resource "aws_s3_bucket_public_access_block" "spa" {
bucket = aws_s3_bucket.spa.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Upload a placeholder index.html to test the CloudFront distribution.
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.spa.id
key = "index.html"
content = "Hello from CloudFront + S3 + OAC!
"
content_type = "text/html"
}
# Origin Access Control — the modern, secure way for CloudFront to access private S3.
resource "aws_cloudfront_origin_access_control" "main" {
name = "${var.bucket_name}-oac"
description = "OAC for ${var.bucket_name} SPA"
origin_access_control_origin_type = "s3"
signing_behavior = "always" # always sign requests to S3
signing_protocol = "sigv4" # use SigV4 signing
}
# Bucket policy — only CloudFront (using OAC SigV4 signing) can read objects.
data "aws_iam_policy_document" "s3_cloudfront" {
statement {
sid = "AllowCloudFrontOAC"
effect = "Allow"
principals { type = "Service"; identifiers = ["cloudfront.amazonaws.com"] }
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.spa.arn}/*"]
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [aws_cloudfront_distribution.spa.arn]
}
}
}
resource "aws_s3_bucket_policy" "spa" {
bucket = aws_s3_bucket.spa.id
policy = data.aws_iam_policy_document.s3_cloudfront.json
depends_on = [aws_cloudfront_distribution.spa]
}
# CloudFront Distribution.
resource "aws_cloudfront_distribution" "spa" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
comment = "${var.environment} SPA distribution"
price_class = "PriceClass_100" # US, Canada, Europe only (cheapest)
# Origin — the private S3 bucket.
origin {
domain_name = aws_s3_bucket.spa.bucket_regional_domain_name
origin_id = "s3-spa-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.main.id
}
# Default cache behaviour.
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-spa-origin"
viewer_protocol_policy = "redirect-to-https" # HTTPS only
compress = true # gzip/brotli compression
# Use the managed caching policy (CachingOptimized).
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
}
# Custom error response — SPA deep-link routing trick.
# S3 returns 403 for paths that don't exist as keys; CloudFront serves index.html instead.
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 0
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = true # use *.cloudfront.net certificate
}
tags = { Environment = var.environment, ManagedBy = "terraform" }
}outputs.tf
output "cloudfront_domain" { value = aws_cloudfront_distribution.spa.domain_name }
output "cloudfront_arn" { value = aws_cloudfront_distribution.spa.arn }
output "s3_bucket_name" { value = aws_s3_bucket.spa.id }Workflow Commands
terraform init
terraform apply
# Test the CloudFront distribution (DNS propagation may take a few minutes)
curl https://$(terraform output -raw cloudfront_domain)/
# Verify that the S3 bucket is not directly accessible
curl https://$(terraform output -raw s3_bucket_name).s3.amazonaws.com/index.html
# Expected: 403 Forbidden or AccessDenied
terraform destroyApply complete! Resources: 6 added, 0 changed, 0 destroyed. Outputs: cloudfront_arn = "arn:aws:cloudfront::123456789012:distribution/ABCDEF1234567" cloudfront_domain = "d1234567890abc.cloudfront.net" s3_bucket_name = "my-spa-static-assets-123456789012"

