LAB19_AWS_CloudFront Distribution with S3 Origin and Origin Access Control

CloudFront Distribution with S3 Origin and Origin Access Control

☁ AWS ⭐ Beginner CloudFront S3 Origin OAC Cache Behaviour HTTPS Only

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).

Your Objectives
  • 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 destroy
✓ Expected Output After Apply
Apply 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"