Moayyad Faris
AWS & DevOps15 min readยท Published 2026-07-23

Hosting a Production Static Website on AWS S3, CloudFront & Route 53 with Free SSL

A complete, step-by-step architectural guide to deploying high-performance static websites using S3, CloudFront Origin Access Control (OAC), ACM SSL certificates, and Route 53 DNS.

Moayyad Faris
VP of Engineering & Software Architect

Based on a production deployment pattern used for this site itself and its Kuybi documentation.

Hosting static websites and single-page applications (SPAs) on AWS S3 behind Amazon CloudFront CDN offers sub-100ms global latency, automatic DDoS mitigation, and near-zero infrastructure maintenance costs. This guide provides the complete production setup process from raw S3 bucket policies to automated GitHub Actions deployment.

1. Production Architecture Overview & Security Model

A secure, enterprise-ready static hosting architecture on AWS relies on four connected AWS services:

  1. Amazon S3: Stores public asset files (HTML, CSS, JS, images, fonts). S3 Public Access is strictly blocked.
  2. Amazon CloudFront: Global Content Delivery Network (CDN) that terminates TLS/SSL at edge locations and caches static files worldwide.
  3. AWS Certificate Manager (ACM): Issues free, auto-renewing SSL/TLS certificates for your custom domain.
  4. Amazon Route 53: Provides low-latency DNS routing with latency-based alias records pointing directly to CloudFront.

Why Avoid Public S3 Website Endpoints?

Legacy guides often recommend enabling *"S3 Static Website Hosting"* and exposing bucket contents publicly. This is an anti-pattern because:

  • It forces HTTP traffic directly to S3 without CloudFront edge caching.
  • It exposes raw S3 URLs (http://my-bucket.s3-website.us-east-1.amazonaws.com), risking direct bucket billing abuse.
  • It does not support native HTTPS custom SSL certificates without an extra load balancer.

Instead, we use CloudFront Origin Access Control (OAC): S3 remains 100% private and responds only to authorized requests signed by your CloudFront distribution.

2. Creating the S3 Bucket & Configuring Origin Access Control (OAC)

Step 2.1: Create the Private S3 Bucket

  1. Open the AWS S3 Console and click Create bucket.
  2. Set Bucket name (e.g. my-domain-static-assets).
  3. Select your primary AWS Region (e.g. us-east-1 or eu-central-1).
  4. Keep Block all public access checked (Enabled).
  5. Keep Bucket Key enabled under Server-side Encryption (SSE-S3). Click Create bucket.

Step 2.2: S3 Bucket Policy for CloudFront OAC

After creating your CloudFront distribution (Step 4), apply the following JSON bucket policy in S3 Console -> Permissions -> Bucket policy:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipalReadOnly",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-domain-static-assets/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7"
        }
      }
    }
  ]
}

3. Requesting a Free ACM SSL Certificate & Route 53 DNS Validation

CloudFront requires custom SSL certificates to be created in the us-east-1 (N. Virginia) Region regardless of where your S3 bucket resides.

Step 3.1: Request Public Certificate in us-east-1

  1. Navigate to AWS Certificate Manager (ACM) in region us-east-1.
  2. Click Request certificate -> Request a public certificate.
  3. Add domain names:
  • example.com
  • *.example.com (wildcard covering subdomains)
  1. Choose DNS validation. Click Request.

Step 3.2: Add CNAME DNS Records in Route 53

  1. Inside the ACM certificate details page, click Create records in Route 53.
  2. ACM will automatically insert the validation CNAME records into your Route 53 Hosted Zone.
  3. Wait 1โ€“3 minutes until certificate status displays Issued.

4. Configuring the CloudFront Distribution & Custom Domain

Step 4.1: Create CloudFront Distribution

  1. Open CloudFront Console and click Create distribution.
  2. Origin domain: Select your S3 bucket (my-domain-static-assets.s3.us-east-1.amazonaws.com).
  3. Origin access: Select Origin access control settings (recommended) -> Click Create control setting (Name: S3-OAC-Setting).
  4. Viewer protocol policy: Select Redirect HTTP to HTTPS.
  5. Allowed HTTP methods: Select GET, HEAD.
  6. Custom SSL certificate: Select your issued ACM certificate for example.com.
  7. Alternate domain name (CNAME): Add example.com and www.example.com.
  8. Default root object: Enter index.html.

Step 4.2: Handling SPA Routing (Optional for Single Page Apps)

For single-page applications (React, Next.js static exports, Vue) that handle client-side routing, add a Custom Error Response in CloudFront:

  • HTTP Error Code: 403 or 404
  • Customize Error Response: Yes
  • Response Page Path: /index.html
  • HTTP Response Code: 200

5. Automated CI/CD Deployment with GitHub Actions

Automating builds ensures that every push to your main branch builds static assets, syncs them to S3, and invalidates CloudFront edge caches.

Add the following pipeline configuration file at .github/workflows/deploy.yml:

yaml
name: Deploy Static Website to AWS S3 & CloudFront

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Node.js Environment
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"

      - name: Install Dependencies
        run: pnpm install --frozen-lockfile

      - name: Build Static Production Export
        run: pnpm 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 Static Files to S3 Bucket
        run: |
          aws s3 sync ./out s3://my-domain-static-assets --delete --cache-control "public, max-age=31536000, immutable"

      - name: Invalidate CloudFront CDN Cache
        run: |
          aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"

Summary Checklist

  • โœ” S3 bucket created with Block All Public Access enabled.
  • โœ” CloudFront Origin Access Control (OAC) configured with S3 Bucket Policy.
  • โœ” ACM SSL Certificate issued in us-east-1 and validated via Route 53 DNS.
  • โœ” CloudFront Distribution created with HTTP->HTTPS redirect and index.html default root object.
  • โœ” GitHub Actions workflow automated with S3 sync and CloudFront CDN invalidation (/*).

Frequently Asked Questions

Why use CloudFront Origin Access Control instead of enabling S3 static website hosting?

S3 Static Website Hosting exposes a public HTTP endpoint directly, bypassing CDN edge caching, and cannot natively serve a custom SSL certificate. Origin Access Control (OAC) keeps the S3 bucket 100% private and signs every request from CloudFront, so the bucket only ever responds to your CDN โ€” never to direct public traffic.

Which AWS region must the ACM SSL certificate be issued in for CloudFront?

The certificate must be requested in us-east-1 (N. Virginia), regardless of which region your S3 bucket or other resources live in โ€” this is a hard CloudFront requirement, not a recommendation.

How do you handle client-side routing (SPA) with CloudFront and S3?

Add a CloudFront Custom Error Response that catches 403/404 responses (which S3 returns for any path that isn't a literal object key) and rewrites them to /index.html with an HTTP 200 status, letting your client-side router take over from there.

How does the GitHub Actions workflow avoid stale CDN content after each deploy?

The workflow's final step runs aws cloudfront create-invalidation with a /* path pattern immediately after syncing new files to S3, forcing every CloudFront edge location to fetch the updated objects instead of serving a cached copy.

References