Skip to content
AWS aws dns-cdn 6 min read

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.

ApproachHTTPSGlobal cachingBucket exposureWhen to use
S3 public website hostingNoNoPublicQuick throwaway demo only
CloudFront + S3 with OAI (legacy)YesYesPrivateAvoid — OAI is deprecated
CloudFront + S3 with OACYesYesPrivateProduction static sites

The pieces you will create

  1. A private S3 bucket with Block Public Access turned on.
  2. 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).
  3. 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.
  4. 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:

  1. Open the S3 console and choose Create bucket.
  2. Name it (e.g. www.example.com), pick a region, and leave Block all public access checked.
  3. Create the bucket, then upload your index.html and 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:

  1. Switch the console region to US East (N. Virginia) us-east-1.
  2. Open Certificate Manager, choose Request a public certificate.
  3. Enter your domain names (e.g. example.com and www.example.com), pick DNS validation, and request it.
  4. 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:

  1. Open the CloudFront console and choose Create distribution.
  2. For Origin domain, pick your S3 bucket. The console offers to use the REST endpoint — accept it.
  3. Under Origin access, choose Origin access control settings (recommended), then Create control setting and save.
  4. Set Viewer protocol policy to Redirect HTTP to HTTPS.
  5. Set Default root object to index.html so that the bare domain serves your homepage.
  6. Under Settings, add your Alternate domain names (CNAMEs) example.com and www.example.com, and select the ACM certificate from step 2.
  7. 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"
      }
    }
  }]
}

Gotcha: If you ship a single-page app (a React, Angular, or Vue site where routing happens in the browser), files like /dashboard do 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 become index.html.

Console steps:

  1. Open your distribution and go to the Error pages tab.
  2. Choose Create custom error response.
  3. 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.
  4. 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:

  1. Open Route 53, choose your hosted zone, and select Create record.
  2. Set the record name (leave blank for the root, or www), toggle Alias on.
  3. 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.html so the bare domain works.
  • For SPAs, map both 403 and 404 to /index.html with 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.
Last updated June 15, 2026
Was this helpful?