Back to blog
Cloud & DevOpsintermediate

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.

LearnixoApril 16, 20265 min read
AWS S3CloudFrontCDNStatic HostingReactAWSDevOps
Share:𝕏

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)

HCL
# 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)

HCL
# 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

HCL
# 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

HCL
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

HCL
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

YAML
# .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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.