S3 + CloudFront: Static Hosting, CDN & Custom Domains on AWS
Host production React apps on AWS — configure S3 static hosting, CloudFront CDN with HTTPS, custom domains via Route 53, cache invalidation, SPA routing, and security headers.
Architecture Overview
Browser
↓ HTTPS
CloudFront (CDN — 400+ edge locations)
↓ origin request (cache miss)
S3 Bucket (private — origin access)Benefits:
- Global CDN — assets served from the edge location closest to the user
- HTTPS by default — ACM certificate attached to CloudFront
- Zero servers — fully managed, scales to millions of requests
- Cost — S3 static hosting + CloudFront is extremely cheap (cents per GB)
Terraform Setup
S3 Bucket (private — access only via CloudFront)
# s3.tf
resource "aws_s3_bucket" "portal" {
bucket = "portal-${var.env}-${data.aws_caller_identity.current.account_id}"
tags = { Environment = var.env }
}
# Block all public access
resource "aws_s3_bucket_public_access_block" "portal" {
bucket = aws_s3_bucket.portal.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Versioning for safe deployments
resource "aws_s3_bucket_versioning" "portal" {
bucket = aws_s3_bucket.portal.id
versioning_configuration { status = "Enabled" }
}
# Origin Access Control — allows CloudFront to read private S3
resource "aws_cloudfront_origin_access_control" "portal" {
name = "portal-oac-${var.env}"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# Bucket policy — only allow CloudFront
resource "aws_s3_bucket_policy" "portal" {
bucket = aws_s3_bucket.portal.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowCloudFrontServicePrincipal"
Effect = "Allow"
Principal = { Service = "cloudfront.amazonaws.com" }
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.portal.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.portal.arn
}
}
}]
})
}ACM Certificate (must be in us-east-1 for CloudFront)
# certificate.tf
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
resource "aws_acm_certificate" "portal" {
provider = aws.us_east_1
domain_name = var.env == "prod" ? "portal.clinic.com" : "${var.env}.portal.clinic.com"
validation_method = "DNS"
lifecycle { create_before_destroy = true }
}
resource "aws_acm_certificate_validation" "portal" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.portal.arn
validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}CloudFront Distribution
# cloudfront.tf
locals {
domain = var.env == "prod" ? "portal.clinic.com" : "${var.env}.portal.clinic.com"
}
resource "aws_cloudfront_distribution" "portal" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
aliases = [local.domain]
price_class = "PriceClass_100" # US, Canada, Europe only — cheapest
origin {
domain_name = aws_s3_bucket.portal.bucket_regional_domain_name
origin_id = "S3-portal"
origin_access_control_id = aws_cloudfront_origin_access_control.portal.id
}
# Default: cache everything with long TTL
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-portal"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 86400 # 1 day
max_ttl = 31536000 # 1 year
# Security headers via response headers policy
response_headers_policy_id = aws_cloudfront_response_headers_policy.security.id
compress = true # Gzip/Brotli compression
}
# index.html — never cache (SPA entry point must be fresh)
ordered_cache_behavior {
path_pattern = "/index.html"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-portal"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# SPA routing — return index.html for all 404s
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 10
}
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 10
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate_validation.portal.certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
tags = { Environment = var.env }
}Security Headers Policy
resource "aws_cloudfront_response_headers_policy" "security" {
name = "portal-security-headers-${var.env}"
security_headers_config {
strict_transport_security {
access_control_max_age_sec = 31536000
include_subdomains = true
preload = true
override = true
}
content_type_options {
override = true
}
frame_options {
frame_option = "DENY"
override = true
}
xss_protection {
mode_block = true
protection = true
override = true
}
referrer_policy {
referrer_policy = "strict-origin-when-cross-origin"
override = true
}
}
}Route 53 DNS
resource "aws_route53_record" "portal" {
zone_id = data.aws_route53_zone.main.zone_id
name = local.domain
type = "A"
alias {
name = aws_cloudfront_distribution.portal.domain_name
zone_id = aws_cloudfront_distribution.portal.hosted_zone_id
evaluate_target_health = false
}
}CI/CD Deployment
# .github/workflows/deploy-frontend.yml
name: Deploy Portal
on:
push:
branches: [main]
paths: ["portal/**"]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: portal/package-lock.json
- name: Install dependencies
working-directory: portal
run: npm ci
- name: Build
working-directory: portal
env:
VITE_API_URL: ${{ secrets.PROD_API_URL }}
VITE_COGNITO_USER_POOL_ID: ${{ secrets.PROD_USER_POOL_ID }}
VITE_COGNITO_CLIENT_ID: ${{ secrets.PROD_CLIENT_ID }}
VITE_AWS_REGION: us-east-1
run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Sync to S3
run: |
# Upload assets with long cache TTL (filenames are hashed by Vite)
aws s3 sync portal/dist/ s3://${{ secrets.S3_BUCKET }} \
--delete \
--exclude "index.html" \
--cache-control "public, max-age=31536000, immutable"
# Upload index.html with no-cache
aws s3 cp portal/dist/index.html s3://${{ secrets.S3_BUCKET }}/index.html \
--cache-control "no-cache, no-store, must-revalidate"
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
--paths "/index.html"Why separate cache headers?
dist/assets/main-abc123.js— Vite adds a content hash to filenames. The file never changes. Cache forever.index.html— no hash, must always be fresh so the browser gets the latest JS/CSS hashes.
SPA Routing Fix
React Router uses client-side routing — portal.clinic.com/patients/123 is not a real S3 key. S3 returns a 403 or 404. The CloudFront custom_error_response above redirects all 403/404s to index.html, letting React Router handle the path.
Cost Estimate
| Service | Cost | |---------|------| | S3 storage (1 GB build artifacts) | ~$0.02/month | | S3 GET requests (10k deploys) | ~$0.004/month | | CloudFront data transfer (10 GB/month) | ~$0.85/month | | CloudFront requests (1M/month) | ~$0.01/month | | Route 53 hosted zone | $0.50/month | | Total | ~$1.50/month |
Compared to a load balancer + EC2 (~$50–200/month), S3 + CloudFront is essentially free for static hosting.
Enjoyed this article?
Explore the Cloud & DevOps learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.