Key Takeaways
- Don't enable S3's legacy "static website hosting" feature with a public bucket — the modern, secure pattern is a private bucket plus CloudFront with Origin Access Control (OAC).
- CloudFront gives you a global CDN, free HTTPS via AWS Certificate Manager, and dramatically lower latency for visitors far from your bucket's region.
amazon s3 website hostingdone right never exposes the bucket URL directly — only the CloudFront distribution domain is public.- Cache invalidation after a deploy is a common gotcha: without it, CloudFront keeps serving the old version from edge caches for up to 24 hours by default.
- Total cost for a low-to-medium traffic static site is typically under $1-2/month — S3 storage, CloudFront requests, and Route 53 hosted zone fees are all priced per use.
The old approach to hosting a static website on S3 — enabling the bucket's native "static website hosting" setting and making the bucket public — still works, but it's outdated: no HTTPS without extra setup, no CDN caching, and a publicly readable bucket that's one misconfigured policy away from a data exposure. The current best practice is a private S3 bucket behind CloudFront, using Origin Access Control so CloudFront is the only thing allowed to read the bucket.
Architecture
<svg viewBox="0 0 640 180" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Static site architecture: visitor to CloudFront to private S3 bucket via Origin Access Control">
<style>
.box { fill: none; stroke: currentColor; stroke-width: 1.5; }
.lbl { fill: currentColor; font-family: sans-serif; font-size: 12px; }
.arrow { stroke: currentColor; stroke-width: 1.5; marker-end: url(#a4); fill:none; }
</style>
<defs>
<marker id="a4" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="currentColor"/>
</marker>
</defs>
<rect class="box" x="10" y="65" width="120" height="50"/>
<text class="lbl" x="25" y="95">Visitor (HTTPS)</text>
<rect class="box" x="190" y="65" width="140" height="50"/>
<text class="lbl" x="205" y="87">CloudFront</text>
<text class="lbl" x="205" y="103">+ ACM cert</text>
<rect class="box" x="400" y="65" width="150" height="50"/>
<text class="lbl" x="412" y="87">S3 bucket</text>
<text class="lbl" x="412" y="103">(private, OAC only)</text>
<path class="arrow" d="M130,90 L190,90"/>
<path class="arrow" d="M330,90 L400,90"/>
</svg>
Step 1: Create a Private S3 Bucket
aws s3api create-bucket \
--bucket my-static-site-f9 \
--region us-east-1
aws s3api put-public-access-block \
--bucket my-static-site-f9 \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
No bucket policy, no public access settings enabled — the bucket is fully private at this point. That's intentional.
Step 2: Upload Your Site Files
aws s3 sync ./dist s3://my-static-site-f9 \
--delete \
--cache-control "public, max-age=86400"
--delete removes files from the bucket that no longer exist locally, keeping the bucket in sync with your build output rather than accumulating stale files over time.
Step 3: Request an ACM Certificate
CloudFront requires certificates in us-east-1 regardless of where your bucket lives:
aws acm request-certificate \
--domain-name www.example.com \
--validation-method DNS \
--region us-east-1
Add the returned CNAME validation record to Route 53 (or your DNS provider) and wait for Status: ISSUED before continuing.
Step 4: Create the CloudFront Distribution with Origin Access Control
aws cloudfront create-origin-access-control \
--origin-access-control-config \
Name=my-static-site-oac,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3
Then create the distribution (abbreviated config shown — full JSON is longer):
{
"CallerReference": "my-static-site-2026",
"Origins": {
"Items": [{
"Id": "S3Origin",
"DomainName": "my-static-site-f9.s3.us-east-1.amazonaws.com",
"OriginAccessControlId": "E1A2B3C4D5E6F7",
"S3OriginConfig": { "OriginAccessIdentity": "" }
}],
"Quantity": 1
},
"DefaultCacheBehavior": {
"TargetOriginId": "S3Origin",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"
},
"DefaultRootObject": "index.html",
"ViewerCertificate": {
"ACMCertificateArn": "arn:aws:acm:us-east-1:111122223333:certificate/abcd1234",
"SSLSupportMethod": "sni-only"
},
"Enabled": true
}
Step 5: Attach a Bucket Policy That Only Allows CloudFront
This is the piece that replaces the old "public bucket" approach — the bucket stays private, and only requests signed by your specific CloudFront distribution are allowed:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-static-site-f9/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1A2B3C4D5E6F7"
}
}
}
]
}
Step 6: Point Your Domain at CloudFront
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890ABC \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "www.example.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "d111111abcdef8.cloudfront.net",
"EvaluateTargetHealth": false
}
}
}]
}'
Z2FDTNDATAQYW2 is CloudFront's fixed alias hosted zone ID — the same for every CloudFront distribution globally, not specific to your account.
Step 7: Invalidate the Cache on Every Deploy
Without this, CloudFront keeps serving cached files for up to a day after you push a new version:
aws cloudfront create-invalidation \
--distribution-id E1A2B3C4D5E6F7 \
--paths "/*"
Add this as the last step of your CI/CD deploy script, right after the s3 sync.
Handling 404s for Single-Page Apps
If you're hosting a React/Vue/Angular SPA, client-side routes like /about don't exist as S3 objects — configure CloudFront to return index.html for 403/404 errors so client-side routing takes over:
{
"CustomErrorResponses": {
"Items": [
{ "ErrorCode": 403, "ResponseCode": 200, "ResponsePagePath": "/index.html" },
{ "ErrorCode": 404, "ResponseCode": 200, "ResponsePagePath": "/index.html" }
]
}
}
FAQ
Should I use S3's built-in "static website hosting" feature? Not for production. It requires a public bucket and doesn't support HTTPS natively. The CloudFront + Origin Access Control pattern is the current recommended approach — private bucket, public only through CloudFront, free HTTPS via ACM.
How much does hosting a static site on S3 and CloudFront cost? For low-to-medium traffic, typically under $1-2/month: S3 storage is fractions of a cent per GB, CloudFront charges per request and data transfer, and there's no compute cost since there's no server.
Why do I need to invalidate the CloudFront cache after deploying? CloudFront caches files at edge locations for the duration set by your cache policy (often 24 hours by default). Without an invalidation, visitors keep getting the old cached version until that TTL expires.
Can I use a custom domain with this setup? Yes — request an ACM certificate for your domain in us-east-1, attach it to the CloudFront distribution, and point your domain's DNS (ideally via a Route 53 alias record) at the CloudFront distribution's domain name.
Need Help Deploying Your Static Site?
f9infotech sets up production-ready S3 + CloudFront hosting — OAC security, CI/CD deploy pipelines, and cache invalidation automation included. Get in touch to launch your site the right way.