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:
- In the CloudFront console, go to Functions → Create function. Give it a
name (e.g.
apex-redirect), select runtimecloudfront-js-2.0, and paste:
function handler(event) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: 'https://www.yourdomain.com' + event.request.uri }
}
};
}
- Click Save, then Publish.
- On the apex CloudFront distribution, go to Behaviors → Default (*) → Edit. Under Function associations → Viewer request, select your new function.
- 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.comas a harmless placeholder. - 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:
- Sign up at cloudflare.com and add your site. Cloudflare will import your existing DNS records.
- 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.
- 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)
- When:
- Make sure the
yourdomain.comDNS 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.com → https://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