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.
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:
- Amazon S3: Stores public asset files (HTML, CSS, JS, images, fonts). S3 Public Access is strictly blocked.
- Amazon CloudFront: Global Content Delivery Network (CDN) that terminates TLS/SSL at edge locations and caches static files worldwide.
- AWS Certificate Manager (ACM): Issues free, auto-renewing SSL/TLS certificates for your custom domain.
- 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
- Open the AWS S3 Console and click Create bucket.
- Set Bucket name (e.g.
my-domain-static-assets). - Select your primary AWS Region (e.g.
us-east-1oreu-central-1). - Keep Block all public access checked (
Enabled). - 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:
{
"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
- Navigate to AWS Certificate Manager (ACM) in region
us-east-1. - Click Request certificate -> Request a public certificate.
- Add domain names:
example.com*.example.com(wildcard covering subdomains)
- Choose DNS validation. Click Request.
Step 3.2: Add CNAME DNS Records in Route 53
- Inside the ACM certificate details page, click Create records in Route 53.
- ACM will automatically insert the validation CNAME records into your Route 53 Hosted Zone.
- Wait 1โ3 minutes until certificate status displays Issued.
4. Configuring the CloudFront Distribution & Custom Domain
Step 4.1: Create CloudFront Distribution
- Open CloudFront Console and click Create distribution.
- Origin domain: Select your S3 bucket (
my-domain-static-assets.s3.us-east-1.amazonaws.com). - Origin access: Select Origin access control settings (recommended) -> Click Create control setting (Name:
S3-OAC-Setting). - Viewer protocol policy: Select Redirect HTTP to HTTPS.
- Allowed HTTP methods: Select
GET, HEAD. - Custom SSL certificate: Select your issued ACM certificate for
example.com. - Alternate domain name (CNAME): Add
example.comandwww.example.com. - 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:
403or404 - 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:
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-1and validated via Route 53 DNS. - โ CloudFront Distribution created with HTTP->HTTPS redirect and
index.htmldefault 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.