Skip to main content

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.

  1. 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.
  2. Origin access: choose Origin access control settings, click Create new OAC, accept the defaults (sign requests, SigV4).
  3. Viewer protocol policy: Redirect HTTP to HTTPS.
  4. Allowed HTTP methods: GET, HEAD — a static site needs nothing else.
  5. Cache policy: CachingOptimized (managed policy — ignores query strings and headers, enables gzip and brotli).
  6. Default root object: index.html.
  7. Leave Custom SSL certificate empty — the default CloudFront certificate covers the *.cloudfront.net domain 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:

bucket-policy.json
{
"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.html as the root object
  • Deploys are s3 sync plus an invalidation