Hosting a Static Site on AWS S3 + CloudFront: A Complete Guide

If you’ve ever tried to migrate a site to S3 + CloudFront you know the documentation makes it look straightforward. It isn’t. This post covers the full setup end-to-end, including the subtle mistakes that will cost you hours if you don’t know about them.


The Architecture

Browser → CloudFront CDN → S3 (website endpoint)
             ↑
     ACM SSL Certificate
  • S3 stores your static files
  • CloudFront is the CDN — handles HTTPS, caching, and global edge delivery
  • ACM provides the free SSL certificate
  • Your registrar (Namecheap, GoDaddy, etc.) points DNS at CloudFront

For a proper www setup you actually need two S3 buckets and two CloudFront distributions — one to serve content, one to redirect the apex domain to www. There are two ways to reduce this overhead (see the alternatives in Step 1): skip the redirect S3 bucket using a CloudFront Function, or eliminate both the bucket and the apex distribution by delegating DNS to Cloudflare.


Step 1 — Create and Configure the S3 Buckets

Content bucket (www.yourdomain.com)

aws s3 mb s3://www.yourdomain.com --region us-east-1

# Disable the "Block Public Access" shield — required for website hosting
aws s3api put-public-access-block \
  --bucket www.yourdomain.com \
  --public-access-block-configuration \
  "BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false"

# Enable static website hosting
aws s3 website s3://www.yourdomain.com \
  --index-document index.html \
  --error-document index.html

# Allow public read
aws s3api put-bucket-policy --bucket www.yourdomain.com --policy '{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "PublicRead",
    "Effect": "Allow",
    "Principal": "*",
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::www.yourdomain.com/*"
  }]
}'

Redirect bucket (yourdomain.com)

This bucket holds no content — it just redirects the apex domain to www. Configure it in the AWS Console: S3 → bucket → Properties → Static website hosting → Redirect requests → Host: www.yourdomain.com, Protocol: https.

Alternative 1 — Skip the redirect bucket with a CloudFront Function (stay in AWS)

The redirect bucket exists because https://yourdomain.com requires a TLS handshake before any redirect can happen — and the S3 redirect bucket itself can’t terminate TLS. CloudFront terminates TLS and then asks the origin what to do. But instead of asking S3, you can attach a CloudFront Function to the apex distribution that issues the 301 directly, before CloudFront ever contacts an origin. No redirect bucket needed.

You still need two CloudFront distributions (one per alternate domain name), but you can delete the S3 redirect bucket.

Steps:

  1. In the CloudFront console, go to Functions → Create function. Give it a name (e.g. apex-redirect), select runtime cloudfront-js-2.0, and paste:
function handler(event) {
    return {
        statusCode: 301,
        statusDescription: 'Moved Permanently',
        headers: {
            location: { value: 'https://www.yourdomain.com' + event.request.uri }
        }
    };
}
  1. Click Save, then Publish.
  2. On the apex CloudFront distribution, go to Behaviors → Default (*) → Edit. Under Function associations → Viewer request, select your new function.
  3. Save and deploy. The origin for the apex distribution is now irrelevant (the function returns before CloudFront contacts it), but CloudFront requires one — point it at www.yourdomain.com.s3-website-us-east-1.amazonaws.com as a harmless placeholder.
  4. Delete the S3 redirect bucket.

CloudFront Function invocations cost $0.10 per million — effectively free for personal-site traffic.

Alternative 2 — Eliminate both the redirect bucket and the apex distribution with Cloudflare (free)

If you’d rather not manage any of this in AWS, Cloudflare’s free tier can handle the apex redirect entirely — no S3 bucket, no second CloudFront distribution. Cloudflare proxies yourdomain.com, terminates TLS using its Universal SSL certificate, and issues the 301 itself.

Here’s how:

  1. Sign up at cloudflare.com and add your site. Cloudflare will import your existing DNS records.
  2. At your registrar (e.g. Namecheap), change the nameservers to the two Cloudflare nameservers it gives you. Your domain stays registered at Namecheap — only DNS moves.
  3. In the Cloudflare dashboard, go to Rules → Redirect Rules and create a rule:
    • When: Hostname equals yourdomain.com
    • Then: Static redirect → https://www.yourdomain.com (301)
  4. Make sure the yourdomain.com DNS record (the @ / apex A or CNAME) has the Cloudflare proxy enabled (orange cloud icon). This is what lets Cloudflare terminate TLS for the apex domain using its Universal SSL certificate — no ACM cert needed for it.

With this setup you only need the single www S3 bucket, one CloudFront distribution, and one ACM certificate. The CNAME for www still points at CloudFront as before.

> Note: Cloudflare acts as a reverse proxy for the apex domain only long > enough to issue the redirect — the actual site content is still served by > your CloudFront distribution.


Step 2 — Request an SSL Certificate

Critical: the certificate must be in us-east-1 regardless of where your bucket lives. CloudFront only reads certificates from us-east-1.

aws acm request-certificate \
  --domain-name www.yourdomain.com \
  --subject-alternative-names yourdomain.com \
  --validation-method DNS \
  --region us-east-1

ACM will give you a CNAME record to add at your registrar for DNS validation. Add it and wait 5–10 minutes for status to change from PENDING_VALIDATION to ISSUED. Don’t proceed until it’s ISSUED — attaching a pending cert to CloudFront causes mysterious TLS errors later.

# Check status
aws acm list-certificates --region us-east-1 \
  --query "CertificateSummaryList[*].[DomainName,Status]" \
  --output table

> Tip: Request one certificate covering both www.yourdomain.com AND > yourdomain.com as a SAN. You can reuse it on both CloudFront distributions.


Step 3 — Create CloudFront Distributions

Create two distributions in the Console. For each:

Setting www (content) apex (redirect)
Origin domain www.yourdomain.com.s3-website-us-east-1.amazonaws.com yourdomain.com.s3-website-us-east-1.amazonaws.com (or www endpoint if using a CloudFront Function — it won’t be called)
Origin protocol HTTP only HTTP only
Alternate domain www.yourdomain.com yourdomain.com
SSL certificate your ACM cert your ACM cert
Default root object index.html (leave blank)
Viewer protocol Redirect HTTP to HTTPS Redirect HTTP to HTTPS
Cache policy CachingOptimized CachingOptimized
Price class North America & Europe North America & Europe
Security policy TLSv1.2_2019 TLSv1.2_2019

Gotcha #1 — Always type the origin manually

When you click the Origin domain field, AWS presents a dropdown of your S3 buckets. Do not use it. The dropdown inserts the REST endpoint (www.yourdomain.com.s3.amazonaws.com). You want the website endpoint (www.yourdomain.com.s3-website-us-east-1.amazonaws.com). Type it manually.

Why it matters: The REST endpoint doesn’t serve directory index documents. So https://www.yourdomain.com/blog works but https://www.yourdomain.com/blog/ returns AccessDenied XML instead of blog/index.html. The website endpoint handles this correctly.

Gotcha #2 — The “add this domain later” warning

If your domain is registered outside Route 53 (e.g. Namecheap, GoDaddy), CloudFront will warn: “You need to add this domain later.” This is harmless — it just means CloudFront can’t auto-add the DNS record for you. Ignore it and proceed. You’ll add the CNAME manually in Step 4.

Gotcha #3 — Use TLSv1.2_2021

Higher versions might lead to handshake failures on some networks and corporate proxies. TLSv1.2_2021 is a safe version that works everywhere. That is what Cloudfront also recommends in the UI.


Step 4 — Upload Your Files

# Sync everything except build/config files
aws s3 sync . s3://www.yourdomain.com \
  --exclude "*.py" --exclude "*.pyc" \
  --exclude "*.yaml" --exclude "*.sh" \
  --exclude ".git/*" --exclude ".vscode/*"

# Fix Content-Type charset on all HTML files (see Gotcha #4 below)
aws s3 cp s3://www.yourdomain.com/ s3://www.yourdomain.com/ \
  --exclude "*" --include "*.html" \
  --no-guess-mime-type \
  --content-type "text/html; charset=utf-8" \
  --metadata-directive REPLACE \
  --recursive

Gotcha #4 — S3 serves HTML without charset, breaking Unicode

By default S3 serves HTML files with Content-Type: text/html — no charset. Browsers fall back to ISO-8859-1, which garbles any non-ASCII characters. The arrow renders as â†'. Smart quotes, em-dashes, and emoji all break.

The fix is to explicitly set charset=utf-8 in the S3 object metadata, as shown in the sync script above. Note that having “ in your HTML is good practice but not sufficient — browsers prioritize the HTTP header over the meta tag.

Verify it worked:

aws s3api head-object \
  --bucket www.yourdomain.com \
  --key index.html \
  --query "ContentType"
# Should return: "text/html; charset=utf-8"

Step 5 — Update DNS at Your Registrar

Add these records (using Namecheap as an example):

Type Host Value
CNAME www xxxx.cloudfront.net (your www distribution)
CNAME @ yyyy.cloudfront.net (your apex redirect distribution)

> Note: Some registrars don’t allow CNAME at the apex (@). If yours doesn’t, > use their URL redirect feature to forward yourdomain.comhttps://www.yourdomain.com, > or transfer DNS management to Route 53 which supports ALIAS records at the apex.

DNS propagation takes anywhere from 5 minutes to 48 hours depending on your registrar and TTL settings.


Step 6 — Ongoing Deploys

Save this as deploy.sh in your project root:

#!/bin/bash
set -eo pipefail

BUCKET="s3://www.yourdomain.com"
DIST_ID="YOUR_CLOUDFRONT_DISTRIBUTION_ID"

echo "Syncing files..."
aws s3 sync . "$BUCKET" \
  --exclude "*.py" --exclude "*.pyc" \
  --exclude "*.yaml" --exclude "*.sh" \
  --exclude ".git/*" --exclude ".vscode/*"

echo "Setting charset on HTML files..."
aws s3 cp "$BUCKET/" "$BUCKET/" \
  --exclude "*" --include "*.html" \
  --no-guess-mime-type \
  --content-type "text/html; charset=utf-8" \
  --metadata-directive REPLACE \
  --recursive

echo "Invalidating CloudFront cache..."
aws cloudfront create-invalidation \
  --distribution-id "$DIST_ID" \
  --paths "/*"

echo "Done."

> Tip: Always invalidate the CloudFront cache after deploying. Without it, > visitors may see the old version for up to 24 hours (the default TTL). The > invalidation is free for the first 1,000 paths per month.


Debugging Cheat Sheet

Symptom Likely cause Fix
AccessDenied XML Using REST endpoint, not website endpoint Update CloudFront origin to s3-website-... URL
NoSuchWebsiteConfiguration Static website hosting not enabled on bucket aws s3 website s3://bucket --index-document index.html
sslv3 alert handshake failure Wrong cert attached, or cert still pending Check cert is ISSUED; check alternate domain is set in distribution
*.cloudfront.net cert shown ACM cert not attached to distribution Edit distribution → set custom SSL cert
Unicode renders as â†' Missing charset in Content-Type header Re-upload HTML with --content-type "text/html; charset=utf-8"
Old content still showing CloudFront cache not invalidated aws cloudfront create-invalidation --paths "/*"
/path works but /path/ doesn’t Using REST endpoint instead of website endpoint Same fix as AccessDenied above

Cost

For a low-traffic personal or portfolio site:

  • S3: < $0.01/month (storage + requests)
  • CloudFront: free tier covers 1TB transfer + 10M requests/month
  • ACM: free
  • Route 53 (if used): $0.50/month per hosted zone

Total: effectively $0/month for personal sites.

Use PAYG pricing in CloudFront, not the Security Bundle (flat rate). The bundle includes AWS WAF and Shield Advanced — useful for high-traffic production apps, overkill (and expensive) for personal sites.

Viewing S3 Storage

 aws s3 ls s3://<name-of-your-bucket> --recursive --human-readable --summarize

Free-plan gives you 5 GB of storage

This entry was posted in Software, Computers, programming and tagged . Bookmark the permalink.

Leave a comment