If you want an S3 static site CloudFront serves properly, the first rule is simple: do not use the S3 website endpoint. It only speaks HTTP, it forces the bucket public, and it gives you no CDN, so every visitor hits one region and your TLS story is nonexistent. The setup that actually belongs in production is a private S3 bucket with Block Public Access fully on, fronted by a CloudFront distribution that reaches the bucket through Origin Access Control (OAC), with an ACM certificate in us-east-1 and a Route 53 alias pointing your domain at the distribution. Nobody touches S3 directly; CloudFront terminates HTTPS at the edge, caches your assets globally, and the bucket policy trusts only that one distribution. I ran the legacy website-endpoint version for a year and regretted it; this is the layout I deploy now.
Why not just flip on S3 static website hosting?
The S3 website hosting feature is the obvious-looking button, and it is the wrong one for anything customer-facing. The website endpoint (the bucket-name.s3-website-region.amazonaws.com hostname) does not support HTTPS at all. To use it you also have to disable Block Public Access and attach a public-read bucket policy, so your bucket is now an open object store on the internet. And there is no CDN in front of it, so a user in Sydney pulls bytes from your bucket's home region every time. You get no edge caching, no TLS, no custom error handling worth the name.
Putting CloudFront in front fixes all three at once. CloudFront gives you a free ACM certificate for your domain, terminates TLS at hundreds of edge locations worldwide, and caches your built assets so the origin is hit rarely. The key piece is Origin Access Control: it lets CloudFront sign requests to S3 with SigV4 so the bucket can stay completely private and still serve content — but only when the request comes from your distribution. OAC is the current mechanism; if you read older guides referencing Origin Access Identity (OAI), that is the legacy approach and AWS now recommends OAC for all new distributions.
Create the private bucket and upload the build
Create the bucket with Block Public Access left fully enabled — all four settings on. The bucket never needs to be public because CloudFront, not the browser, is the only thing that reads from it. Region does not matter much for the bucket itself; pick one close to where you deploy from. Then sync your built site up. The aws s3 sync command with --delete keeps the bucket as an exact mirror of your local build directory, removing stale files from previous releases.
# Create a private bucket (Block Public Access stays on by default in new buckets)
aws s3api create-bucket \
--bucket example-com-site \
--region ap-southeast-2 \
--create-bucket-configuration LocationConstraint=ap-southeast-2
# Confirm all four Block Public Access settings are ON
aws s3api put-public-access-block \
--bucket example-com-site \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
# Upload the build, mirroring local dist/ exactly
aws s3 sync ./dist s3://example-com-site --deleteOne gotcha on create-bucket: for every region except us-east-1 you must pass --create-bucket-configuration LocationConstraint, and it must match --region. Omit it outside us-east-1 and the call fails with IllegalLocationConstraintException. In us-east-1 you must NOT pass it at all.
How does CloudFront read a private bucket with OAC?
This is the part people get wrong, so go slow. You create a CloudFront distribution whose origin is the bucket's REST endpoint (example-com-site.s3.ap-southeast-2.amazonaws.com), not the website endpoint. You attach an Origin Access Control to that origin. Then you add a bucket policy that grants s3:GetObject to the CloudFront service principal, scoped by a condition to your specific distribution ARN. Without that condition, any CloudFront distribution in any AWS account could read your bucket — the SourceArn check is what nails access to yours alone.
Set the distribution's Default Root Object to index.html so a request for the bare domain returns your homepage rather than an XML listing. The bucket policy below is the exact shape I use; replace the account id and distribution id with your own.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::example-com-site/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1ABCDEF2GHIJK"
}
}
}
]
}aws s3api put-bucket-policy \
--bucket example-com-site \
--policy file://bucket-policy.jsonIf you want the deeper reasoning on why this condition-scoped resource policy is safer than handing an IAM role broad S3 rights, I wrote that up separately in S3 bucket policies vs IAM policies. The short version: this grant lives on the resource, is readable in one place, and trusts a service principal, not a long-lived key.
Getting the ACM certificate and Route 53 alias right
Here is the single most common stumble: CloudFront only accepts ACM certificates issued in us-east-1 (N. Virginia), no matter where your bucket or your users are. Request the cert in any other region and it will simply not appear in the CloudFront alternate-domain dropdown. So request it in us-east-1, validate it by DNS, then attach it.
# Cert MUST be in us-east-1 for CloudFront, regardless of bucket region
aws acm request-certificate \
--region us-east-1 \
--domain-name example.com \
--subject-alternative-names www.example.com \
--validation-method DNSDNS validation hands you a CNAME record to add to your hosted zone; once Route 53 serves it, ACM issues the cert within minutes. With Route 53 as your DNS, the cleanest path is an alias record, not a CNAME. An alias A record points the apex (example.com) straight at the CloudFront distribution, which a plain CNAME cannot do at a zone apex. The full Route 53 walkthrough — hosted zones, alias targets, and the apex-CNAME problem — is in my AWS Route 53 DNS setup guide, and the certificate lifecycle itself I covered in free SSL on AWS with ACM.
- Add example.com and www.example.com to the distribution's Alternate Domain Names (CNAMEs), or the browser throws an SSL_ERROR / hostname-mismatch.
- Set Viewer Protocol Policy to Redirect HTTP to HTTPS so no one is ever served plaintext.
- Create a Route 53 alias A record (and AAAA for IPv6) targeting the d111111abcdef8.cloudfront.net hostname, with Evaluate Target Health off.
- Leave the alias hosted-zone ID as CloudFront's fixed zone — Route 53 fills this in automatically when you pick the distribution as the alias target.
How do you stop a single-page app from 404ing on refresh?
If you ship a React, Vue, or any client-routed SPA, deep links break the moment someone refreshes /dashboard or pastes it into a new tab. S3 has no object at that key, so it returns 403 (with Block Public Access on and a private bucket, a missing object reads as 403, not 404), CloudFront passes that through, and your user sees an access-denied page instead of the app. The fix is CloudFront custom error responses: catch the 403 and 404, serve /index.html, and rewrite the HTTP status to 200 so the browser hands control to your client router.
"CustomErrorResponses": {
"Quantity": 2,
"Items": [
{
"ErrorCode": 403,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 10
},
{
"ErrorCode": 404,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 10
}
]
}Keep ErrorCachingMinTTL low (10 seconds, not the 300 default). Otherwise CloudFront caches the rewritten index.html against a path that genuinely should 404 once you add it, and you wait five minutes wondering why a real page still shows the app shell. If you serve a true static multi-page site rather than an SPA, skip this section entirely — you do not want legitimate 404s masked.
The S3 website endpoint is HTTP-only and forces your bucket public. If it is in front of customers, you have already shipped the bug.
Invalidate the cache on every deploy
CloudFront caches by the Cache-Control headers on your objects, and for a static site you want long TTLs on hashed assets (app.4f3a1b.js) so they cache aggressively. But index.html and any unhashed file must be refreshed on deploy, or visitors keep getting last week's HTML pointing at JS bundles that no longer exist — a white screen with a console full of 404s. After every aws s3 sync, fire an invalidation. Be precise: invalidate /index.html and your unhashed paths, not /* blindly, because the first 1,000 invalidation paths per month are free across your account and after that AWS bills per path.
# Run after every successful upload
aws cloudfront create-invalidation \
--distribution-id E1ABCDEF2GHIJK \
--paths "/index.html" "/service-worker.js"
# For an SPA where everything routes through index.html, this one path is
# usually enough. Avoid "/*" unless you actually changed every file.Wire that sync-plus-invalidate pair into your CI deploy step and the whole thing becomes a single command on merge. Keeping invalidations narrow is also a real line item once you scale: a path that includes the * wildcard counts as a single path, but a deploy that blindly lists every file can blow past the free 1,000 in a busy month, and that overage is one of those small recurring charges nobody notices until the bill arrives.
What this buys you
You end up with a static site that is genuinely private at the origin, served over HTTPS on your own domain, cached at the edge worldwide, and resilient to SPA deep-link refreshes — none of which the legacy website endpoint gives you. The architecture is four moving parts: a locked-down bucket, a CloudFront distribution with OAC, an ACM cert in us-east-1, and a Route 53 alias. Get the SourceArn condition and the us-east-1 cert right and the rest is mechanical.
Once it is live, the work shifts from plumbing to performance: set sane Cache-Control headers, lean on the edge, and watch your real-user metrics. The cache layer is doing most of the heavy lifting for your load times, so tune it against actual field data rather than synthetic numbers. Ship the private-origin version once and you will never reach for the S3 website endpoint again.

