Hands-On: Serve a Static Site Through CloudFront
What We Are Building
I have a React build in a local dist/ folder. By the end of this walkthrough it is hosted in a private S3 bucket, served globally through CloudFront over HTTPS on the default *.cloudfront.net certificate, and I can push updates without users seeing stale files.
Step 1: Create the Bucket and Upload the Build
The bucket stays fully private — CloudFront will be the only reader.
aws s3 mb s3://my-react-site-2749 --region us-east-1
aws s3api put-public-access-block \
--bucket my-react-site-2749 \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3 sync ./dist s3://my-react-site-2749 --delete
Do not enable S3 static website hosting. That feature is HTTP-only and public; with CloudFront in front, the plain bucket endpoint is the right origin.
Step 2: Create the Distribution with Origin Access Control
Console path: CloudFront → Create distribution.
- Origin domain: pick the bucket from the dropdown —
my-react-site-2749.s3.us-east-1.amazonaws.com. Do not use the website endpoint variant. - Origin access: choose Origin access control settings, click Create new OAC, accept the defaults (sign requests, SigV4).
- Viewer protocol policy: Redirect HTTP to HTTPS.
- Allowed HTTP methods: GET, HEAD — a static site needs nothing else.
- Cache policy: CachingOptimized (managed policy — ignores query strings and headers, enables gzip and brotli).
- Default root object:
index.html. - Leave Custom SSL certificate empty — the default CloudFront certificate covers the
*.cloudfront.netdomain with HTTPS out of the box.
Create the distribution and note its ID and domain, something like E1A2B3C4D5E6F7 and d1abc2def3ghi.cloudfront.net. Deployment takes 5–10 minutes.
Step 3: Allow CloudFront to Read the Bucket
OAC signs requests as the CloudFront service principal, so the bucket policy must admit it. The console offers to copy this policy after creation; applying it by hand looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontOAC",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-react-site-2749/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7"
}
}
}
]
}
aws s3api put-bucket-policy \
--bucket my-react-site-2749 \
--policy file://bucket-policy.json
The AWS:SourceArn condition pins access to this one distribution — another account's CloudFront cannot read the bucket.
Step 4: Handle the SPA Routing Case
A React Router app breaks on refresh: a request for /dashboard has no matching S3 object, and S3 returns 403 to CloudFront. Map that error back to the app shell under Error pages → Create custom error response:
- HTTP error code: 403 Forbidden
- Customize error response: Yes
- Response page path:
/index.html - HTTP response code: 200
Repeat for 404 if you like. Skip this step entirely for a plain static site where every path is a real file.
Step 5: Test the Distribution
Once the status flips to Deployed:
curl -I https://d1abc2def3ghi.cloudfront.net
HTTP/2 200
content-type: text/html
x-cache: Miss from cloudfront
Run it again and x-cache becomes Hit from cloudfront — the edge served it without touching S3. Also confirm the origin is sealed:
curl -I https://my-react-site-2749.s3.us-east-1.amazonaws.com/index.html
That should return 403. All traffic now has to come through CloudFront.
Step 6: Deploy an Update and Invalidate the Cache
CachingOptimized keeps objects at the edge for up to 24 hours by default, so a fresh deploy will not show up on its own. Sync, then invalidate:
aws s3 sync ./dist s3://my-react-site-2749 --delete
aws cloudfront create-invalidation \
--distribution-id E1A2B3C4D5E6F7 \
--paths "/*"
Watch it complete (usually under a minute):
aws cloudfront get-invalidation \
--distribution-id E1A2B3C4D5E6F7 \
--id I2J3K4L5M6N7O8 \
--query "Invalidation.Status"
A note on cost: /* counts as one path, and the first 1,000 paths a month are free — so for a low-traffic site, blanket invalidation on each deploy is fine. Since webpack and Vite already hash asset filenames, the tighter habit is invalidating only /index.html and letting the hashed bundles cache forever.
Step 7: Verify HTTPS Behavior
The default certificate needs zero setup, but confirm the redirect policy works:
curl -sI http://d1abc2def3ghi.cloudfront.net | head -3
HTTP/1.1 301 Moved Permanently
location: https://d1abc2def3ghi.cloudfront.net/
Plain HTTP is bounced to HTTPS at the edge. When you later attach a custom domain, you will request a free ACM certificate in us-east-1 and add the domain as an alternate name — the distribution itself does not change.
Recap
- Private S3 bucket, no website hosting, no public access
- CloudFront distribution with OAC as the only allowed reader
- CachingOptimized behavior, HTTPS redirect,
index.htmlas the root object - Deploys are
s3 syncplus an invalidation