Serving a Static Site via CloudFront + S3
Hosting a static website (HTML, CSS, JavaScript, and images with no server-side code) is one of the most common things you will do on AWS. The modern, production-grade pattern is to store your files in a private Amazon S3 (Simple Storage Service — AWS object storage) bucket, then put CloudFront (AWS’s CDN, a Content Delivery Network that caches your files at edge locations around the world) in front of it. This gives you fast global delivery, free HTTPS, and a setup where the public can never read your bucket directly. This page walks through that pattern end to end.
Why not just make the bucket public?
S3 has a “static website hosting” mode that serves files straight from a public bucket. It works, but it is the wrong choice for anything real:
- It only serves over HTTP, not HTTPS, so you cannot attach a free TLS certificate.
- There is no caching layer, so every request hits S3 from wherever the user is — slow for distant users.
- A public bucket is a classic source of data leaks if you ever store the wrong file in it.
The CloudFront + S3 pattern fixes all three. S3 stays locked down, CloudFront serves over HTTPS from edge locations, and only CloudFront is allowed to read the bucket.
| Approach | HTTPS | Global caching | Bucket exposure | When to use |
|---|---|---|---|---|
| S3 public website hosting | No | No | Public | Quick throwaway demo only |
| CloudFront + S3 with OAI (legacy) | Yes | Yes | Private | Avoid — OAI is deprecated |
| CloudFront + S3 with OAC | Yes | Yes | Private | Production static sites |
The pieces you will create
- A private S3 bucket with Block Public Access turned on.
- A CloudFront distribution that uses Origin Access Control (OAC) to read the bucket. OAC is the modern, signed way for CloudFront to authenticate to S3; it replaces the older Origin Access Identity (OAI).
- An ACM certificate (AWS Certificate Manager — free public TLS/SSL certificates) for your domain, created in the us-east-1 region because CloudFront only accepts certificates from there.
- A Route 53 Alias record pointing your domain at the distribution. Route 53 is AWS’s DNS service; an Alias is a special record that points a domain at an AWS resource for free.
Step 1 — Create the private bucket and upload files
Console steps:
- Open the S3 console and choose Create bucket.
- Name it (e.g.
www.example.com), pick a region, and leave Block all public access checked. - Create the bucket, then upload your
index.htmland other site files.
CLI:
aws s3 mb s3://www.example.com --region us-east-1
aws s3 cp ./dist/ s3://www.example.com/ --recursive
Output:
make_bucket: www.example.com
upload: dist/index.html to s3://www.example.com/index.html
upload: dist/assets/app.css to s3://www.example.com/assets/app.css
The bucket is completely private. You cannot open the files in a browser yet — that is expected and correct.
Step 2 — Request the ACM certificate (us-east-1)
Gotcha: CloudFront only reads certificates from us-east-1, no matter where your bucket or users are. If you request the cert in another region, it will not appear in the CloudFront dropdown.
Console steps:
- Switch the console region to US East (N. Virginia) us-east-1.
- Open Certificate Manager, choose Request a public certificate.
- Enter your domain names (e.g.
example.comandwww.example.com), pick DNS validation, and request it. - Click Create records in Route 53 to auto-add the validation records, then wait for status Issued.
CLI:
aws acm request-certificate \
--domain-name example.com \
--subject-alternative-names www.example.com \
--validation-method DNS \
--region us-east-1
Output:
{
"CertificateArn": "arn:aws:acm:us-east-1:111122223333:certificate/0a1b2c3d-4e5f-6789-abcd-ef0123456789"
}
ACM certificates are free. You only pay for the AWS resources that use them.
Step 3 — Create the CloudFront distribution with OAC
This is where the security model comes together. CloudFront signs every request to S3 with OAC, and a bucket policy grants read access only to that distribution.
Console steps:
- Open the CloudFront console and choose Create distribution.
- For Origin domain, pick your S3 bucket. The console offers to use the REST endpoint — accept it.
- Under Origin access, choose Origin access control settings (recommended), then Create control setting and save.
- Set Viewer protocol policy to Redirect HTTP to HTTPS.
- Set Default root object to
index.htmlso that the bare domain serves your homepage. - Under Settings, add your Alternate domain names (CNAMEs)
example.comandwww.example.com, and select the ACM certificate from step 2. - Create the distribution. CloudFront shows a banner with a one-click button to copy the bucket policy — apply it to the bucket so CloudFront can read it.
The generated bucket policy looks like this (the Condition ties access to your specific distribution):
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::www.example.com/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1A2B3C4D5E6F7"
}
}
}]
}
Step 4 — Handle SPA routing (the deep-link gotcha)
Gotcha: If you ship a single-page app (a React, Angular, or Vue site where routing happens in the browser), files like
/dashboarddo not exist in S3. A direct visit or refresh returns a 403/404 instead of your app. Set up a custom error response so those becomeindex.html.
Console steps:
- Open your distribution and go to the Error pages tab.
- Choose Create custom error response.
- Set HTTP error code to 403: Forbidden, Customize error response to Yes, Response page path to
/index.html, and HTTP Response code to 200: OK. - Repeat for 404: Not Found.
S3 returns 403 (not 404) for missing objects when the bucket is private, so map both codes to be safe. Skip this step entirely if you have a plain multi-page static site where every URL maps to a real file — you do not want to mask genuine 404s.
Step 5 — Point your domain at CloudFront with Route 53
Console steps:
- Open Route 53, choose your hosted zone, and select Create record.
- Set the record name (leave blank for the root, or
www), toggle Alias on. - Route traffic to Alias to CloudFront distribution and pick your distribution. Save.
CLI:
aws route53 change-resource-record-sets \
--hosted-zone-id Z0123456789ABCDEFGHIJ \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "www.example.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "d111111abcdef8.cloudfront.net",
"EvaluateTargetHealth": false
}
}
}]
}'
Output:
{
"ChangeInfo": {
"Status": "PENDING",
"Id": "/change/C0987654321ZYXWV"
}
}
The hosted zone ID Z2FDTNDATAQYW2 is a fixed, global value that always means “CloudFront” — do not change it.
Cost note
This stack is cheap for a typical site. S3 storage is about $0.023 per GB per month, CloudFront data transfer out starts around $0.085 per GB (with a generous always-free tier of 1 TB/month and 10 million requests/month), and Route 53 hosted zones cost $0.50/month. ACM certificates are free. A small personal site usually costs well under a dollar a month.
Best practices
- Always use OAC, never OAI (deprecated) and never a public bucket — only CloudFront should be able to read S3.
- Keep Block Public Access fully enabled on the bucket; the OAC bucket policy is all CloudFront needs.
- Set a default root object of
index.htmlso the bare domain works. - For SPAs, map both 403 and 404 to
/index.htmlwith a 200 response so deep links and refreshes work. - Request the ACM certificate in us-east-1 or it will not show up for CloudFront.
- Run a CloudFront invalidation (
aws cloudfront create-invalidation) after each deploy so users get fresh files, or use content-hashed filenames to avoid invalidations entirely. - Use the Route 53 Alias (not a CNAME) for the root domain so the apex works and stays free.