What You Need
- Hardware: Any computer with a web browser
- Software: An AWS account, your site's files, and optionally a domain name. CloudFront's monthly free allowance applies to every account; S3's free usage depends on your account's Free Tier plan, which changed for accounts created on or after July 15, 2025. AWS CLI v2 for the deploy script.
- Versions: The console steps and prices were checked against the AWS documentation in October 2026. The AWS console layout and the Free Tier rules change often, so treat the names of buttons and menus as a guide and follow the linked pages where they differ. The deploy script needs AWS CLI v2.
- Time: 30-60 minutes, plus waiting for DNS and certificate validation
Upload files to a private bucket → Create a CloudFront distribution with Origin Access Control (OAC) → Request a free ACM certificate in us-east-1 → Add your domain to CloudFront → Point DNS at the distribution. S3's "static website hosting" feature is not used: with OAC, CloudFront reads from the bucket's normal REST endpoint.
💡 OAC, not OAI: older guides and some console screens still mention Origin Access Identity (OAI). AWS now calls OAI legacy and recommends Origin Access Control: OAI doesn't support SSE-KMS encryption, dynamic requests (PUT and DELETE), or buckets in opt-in Regions launched after December 2022. If you already have an OAI distribution, AWS documents a migration that allows both in the bucket policy during the switch (see Restrict access to an Amazon S3 origin).
What You'll Need
- An AWS account
- Static site files (even a single index.html works)
- Domain name (optional — covered below)
No site yet? Use this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My First AWS Site</title>
</head>
<body>
<h1>Hello from S3!</h1>
<p>If you can read this, it's working.</p>
</body>
</html>
Step 1: Create an S3 Bucket
S3 holds your files. It's durable object storage that handles large request volumes without any tuning, and storing a small site costs very little.
- Open the S3 Console
- Click Create bucket
- Pick a bucket name. It must be globally unique; something based on your domain, such as
example-com-site, is easy to recognise - Select a Region near your users
- Leave Object Ownership at ACLs disabled (Bucket owner enforced), the default. OAC requires it
- Keep "Block all public access" ON — CloudFront handles access, not S3
- Click Create bucket
Don't unblock public access. Many older tutorials do, because they serve the site from the S3 website endpoint. With CloudFront and OAC the bucket stays private and only your distribution can read it.
Upload Your Files
- Open the bucket
- Click Upload
- Drag in everything — HTML, CSS, images, JS
- Click Upload
With the AWS CLI, the same upload is aws s3 sync ./public s3://your-bucket-name, where ./public is the folder that holds index.html. Upload the folder's contents, not the folder itself: index.html should sit at the top of the bucket, not under public/.
Files are in S3 now but nobody can reach them directly, which is the point. You can confirm it:
# Lists index.html at the top level
aws s3 ls s3://your-bucket-name/
# A direct request should get HTTP 403, because the bucket is private
curl -I https://your-bucket-name.s3.us-east-1.amazonaws.com/index.html
Use your bucket's Region in the hostname. CloudFront is next.
Step 2: Create a CloudFront Distribution
CloudFront is the CDN layer. It caches your files at hundreds of edge locations, so a visitor is usually served from a location near them rather than from the bucket's Region.
It also solves the access problem: OAC (Origin Access Control) lets CloudFront sign its requests to a private bucket. The bucket stays locked down.
- Open the CloudFront Console
- Click Create distribution
The console's create flow has been reorganised several times. The current one may first ask you to choose between a flat-rate pricing plan (Free, Pro, Business or Premium tiers) and pay-as-you-go, and it can create the OAC and bucket policy for you. This guide assumes pay-as-you-go; the cost section covers what the plans change. Whatever the layout, these are the settings that matter:
Origin Settings
- Origin: pick your S3 bucket from the list. If the console suggests the S3 website endpoint, don't use it: OAC only works with the bucket's REST endpoint (
bucket-name.s3.region.amazonaws.com). - Origin access: Origin access control (recommended). Create a new OAC with the default "Sign requests" behaviour.
- CloudFront then needs a bucket policy that lets this distribution read the bucket. Some console versions apply it for you; otherwise you copy it in the next step.
Default Cache Behavior
- Viewer protocol policy: "Redirect HTTP to HTTPS"
- Allowed HTTP methods: GET, HEAD. Static site. Nothing else needed.
- Cache policy: the managed
CachingOptimizedpolicy is a sensible default for static files.
Settings
- Default root object:
index.html. Without it, a request for/asks S3 for an empty key and you get an XML error page instead of your site. - Defaults for the rest.
- Click Create distribution
If CloudFront shows a banner about the bucket policy, click Copy policy.
Update Bucket Policy
- Back to S3. Open the bucket.
- Permissions tab
- Bucket policy → Edit
- Paste the policy CloudFront generated
- Save
That policy allows the cloudfront.amazonaws.com service principal to call s3:GetObject on arn:aws:s3:::your-bucket/*, with a condition that AWS:SourceArn equals your distribution's ARN. Nothing else gets in. If the bucket uses SSE-KMS, the KMS key policy also has to allow the distribution; the AWS page linked above shows that statement.
Wait for Deployment
A new distribution takes a while to deploy to all edge locations; the console shows "Deploying" under Last modified until it finishes.
When it shows a date instead, grab the Distribution domain name (something like
d1234abcd.cloudfront.net). Open it. Your site should load over HTTPS. From a terminal, run the request twice:
curl -sI https://d1234abcd.cloudfront.net/ | grep -iE '^HTTP|^x-cache'
Expect a 200 status both times, with x-cache: Miss from cloudfront on the first request and Hit from cloudfront on the second once the edge has cached it. A 403 here means CloudFront can't read the bucket; see "Access Denied" below.
Step 3: Connect Your Domain (Route 53)
The CloudFront URL works but isn't something you'd put on a business card. Three pieces connect your own domain: a certificate, the domain listed on the distribution, and a DNS record. Do the certificate first, because CloudFront won't accept an alternate domain name without a matching certificate.
Get a Free SSL Certificate
- Switch the console Region to US East (N. Virginia) us-east-1. CloudFront only uses ACM certificates from that Region, wherever your bucket is (see CloudFront certificate requirements)
- ACM → Request a certificate → Public certificate
- Domain names:
example.comand*.example.com(the wildcard coverswwwand other subdomains, but not the bare domain, so list both) - DNS validation. ACM renews DNS-validated certificates automatically as long as the validation record stays in place
- ACM generates CNAME records. Add them to Route 53 (there's a button for that if the zone is in Route 53) or at your DNS provider
- Wait for the status to change from "Pending validation" to "Issued"
Public ACM certificates used with CloudFront don't cost anything.
Update CloudFront with Your Domain
People skip this, then wonder why the custom domain returns a CloudFront error. The distribution has to know which hostnames it serves.
- CloudFront → your distribution → Edit (under Settings)
- Alternate domain names (CNAMEs): add
example.comandwww.example.com - Custom SSL certificate: pick the certificate you just issued
- Save, and wait for the change to deploy
Option A: DNS on Route 53
Domain registered through Route 53, or its DNS hosted there:
- Route 53 → Hosted zones
- Click the domain
- Create record
- Record name: blank for root, or
www - Alias: ON
- Route traffic to: Alias to CloudFront distribution
- Select the distribution
- Create record
Create the record as type A, and repeat with type AAAA if IPv6 is enabled on the distribution. Route 53 doesn't charge for queries to alias records that point at a CloudFront distribution.
Option B: DNS Elsewhere
Domain whose DNS is hosted by your registrar or another provider:
- Go to your DNS provider's settings
- For
www, add a CNAME pointing to your distribution domain name (d1234abcd.cloudfront.net) - The bare domain (
example.com) can't be a CNAME under the DNS rules. You need a provider-specific ALIAS/ANAME/flattened record, or move the domain's DNS to Route 53, or redirect the bare domain towwwat your provider
DNS changes take effect as resolvers' cached records expire, so how quickly you see the new record depends on the TTL of whatever was there before. Networking Fundamentals explains how DNS resolution and TTLs work.
Check both hostnames once the record exists. dig should return CloudFront addresses (for a CNAME, the cloudfront.net name first), and curl should return 200 over HTTPS without a certificate error:
dig +short example.com
dig +short www.example.com
curl -I https://example.com/
curl -I https://www.example.com/
What About the Bill?
CloudFront has an always-free monthly allowance of 1 TB of data transfer out, 10 million HTTP/HTTPS requests and 2 million CloudFront Function invocations. AWS made it permanent (no longer limited to the first 12 months) in December 2021, per the AWS announcement. CloudFront also offers flat-rate pricing plans in Free, Pro, Business and Premium tiers. They bundle delivery with AWS WAF, DDoS protection, Route 53 DNS and some S3 storage credit for a fixed monthly price with no overage charges; current allowances are on the CloudFront pricing page. One catch for cleanup: a distribution subscribed to a plan can't be deleted until you cancel the plan and the current billing cycle ends.
For a small site that leaves S3 storage and requests (small amounts, and partly covered by the free tier depending on your plan) and the Route 53 hosted zone, which has a fixed monthly fee. Transfer from S3 to CloudFront isn't charged as data transfer out.
Watch out for logging. S3 server access logs and CloudFront standard logs write a steady stream of log objects. If you enable them, add an S3 lifecycle rule that expires old logs so the log files don't outgrow the site.
Common Issues and Fixes
"Access Denied" When Visiting the Site
Usually one of these:
- The file doesn't exist. Without
s3:ListBucket, S3 answers a request for a missing key with 403 Access Denied rather than 404. Check the exact key, including case:About.htmlandabout.htmlare different objects. - The bucket policy is wrong. The resource must be
arn:aws:s3:::your-bucket/*(with/*), and theAWS:SourceArncondition must match this distribution's ARN. A policy copied from another distribution fails. - The origin is the website endpoint, not the REST endpoint. OAC doesn't work with website endpoints.
- SSE-KMS encryption without a key policy statement for CloudFront.
Website Shows XML Error Instead of Page
Default root object is missing. CloudFront distribution → Edit → set it to index.html.
Subdirectory URLs Return an Error
The default root object only applies to the root URL. A request for /about/ asks S3 for the key about/, not about/index.html, and because the bucket is private the missing key comes back as 403. Either link to full file names, or rewrite the URI at the edge with a CloudFront Function. AWS publishes this pattern as an example function; a version of it:
async function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
// /about/ -> /about/index.html
request.uri += 'index.html';
} else if (!uri.split('/').pop().includes('.')) {
// /about -> /about/index.html (no file extension in the last segment)
request.uri += '/index.html';
}
return request;
}
- CloudFront → Functions → Create function, runtime
cloudfront-js-2.0 - Paste the code, Save changes, then on the Publish tab choose Publish function
- Add an association: your distribution, event type Viewer Request, cache behavior Default (*)
After the distribution finishes deploying, curl -I https://example.com/about/ should return 200. The function's Test tab lets you try a URI before publishing.
Changes Not Showing Up
CloudFront is serving cached copies. Invalidate them: in the console, open the distribution, then Invalidations → Create invalidation and enter /* to clear everything. From the CLI:
aws cloudfront create-invalidation --distribution-id E1234567890ABC --paths "/*"
# Status changes from InProgress to Completed
aws cloudfront list-invalidations --distribution-id E1234567890ABC \
--query "InvalidationList.Items[0].Status" --output text
Or wait for the cached objects to expire (the CachingOptimized policy's default TTL is 24 hours). The first 1,000 invalidation paths each month are free, and /* counts as one path (see Pay for file invalidation).
HTTPS Not Working
Check four things: the ACM certificate is in us-east-1, its status is "Issued" (not "Pending validation"), it's selected in the distribution, and the domain appears in "Alternate domain names." The certificate must cover the exact hostname the browser asked for.
The Architecture
Full request path, top to bottom:
User Request
↓
Route 53 (DNS: example.com → CloudFront)
↓
CloudFront (CDN - edge location nearest to user)
↓ (cache miss? fetch from origin)
S3 Bucket (private, only CloudFront can access)
↓
Returns file → CloudFront caches it → Serves to user
S3 is storage. CloudFront is delivery and HTTPS. Route 53 is DNS. Each does one thing, and none of them needs capacity planning as traffic grows; costs follow usage, apart from the fixed hosted-zone fee.
Automating Deploys
Uploading through the console gets old fast. Here is a deploy script for the AWS CLI:
deploy.sh syncs the site to S3; --delete removes files you deleted locally.#!/bin/bash
set -euo pipefail
BUCKET="your-bucket-name"
DISTRIBUTION_ID="your-cloudfront-id"
# Sync files to S3 (--delete removes files that no longer exist locally)
aws s3 sync ./public "s3://$BUCKET" --delete
# Invalidate CloudFront cache
aws cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION_ID" \
--paths "/*"
echo "Deployed!"
Run ./deploy.sh and the new files are live once the invalidation completes. The credentials that run it only need s3:ListBucket, s3:PutObject and s3:DeleteObject on this bucket plus cloudfront:CreateInvalidation on this distribution; Secure Your AWS Account with IAM shows how to scope a policy like that. In CI, prefer a role assumed through OIDC over stored access keys.
What You Pay For
Prices vary by Region and change over time, so this table lists what is billed rather than copying every rate. The Route 53 figures are from the Route 53 pricing page as of October 2026; see the S3 pricing and CloudFront pricing pages for the others.
| Service | Billed for | Free allowance |
|---|---|---|
| S3 Storage and requests | GB-months stored, plus GET/PUT requests | Depends on your account's Free Tier plan |
| CloudFront Transfer | GB delivered to viewers (pay-as-you-go), or a flat-rate plan | 1 TB/month, always free |
| CloudFront Requests | HTTP/HTTPS requests | 10M requests/month, always free |
| CloudFront Invalidations | Paths beyond the free allowance | 1,000 paths/month |
| Route 53 Hosted Zone | $0.50/zone/month (first 25 zones) | None (a zone deleted within 12 hours isn't charged) |
| Route 53 Queries | $0.40/million standard queries | Alias queries to CloudFront are free |
| ACM (Certificate Manager) | Nothing for public certificates used with CloudFront | — |
Tearing It Down
If this was a test, remove the pieces in this order. AWS recommends deleting a distribution that uses OAC before deleting its S3 bucket origin, and a disabled distribution has to finish deploying before it can be deleted.
rb --force empties and deletes the bucket, and the check after it should fail with NoSuchBucket.- Pricing plan: if you subscribed the distribution to a flat-rate plan, cancel it first. The distribution can only be deleted after the current billing cycle ends.
- Distribution: CloudFront → select it → Disable. Wait until Last modified shows a date instead of "Deploying", then Delete. Disabling it stops it answering for your domain straight away.
- CloudFront Function and OAC: delete them under Functions and Origin access once nothing references them.
- DNS records: delete the A/AAAA alias records (or the CNAME at your provider) and the ACM validation CNAMEs.
- Certificate: in ACM (us-east-1), delete the certificate. ACM refuses while it's still associated with a distribution.
- Bucket: empty and delete it.
- Hosted zone: only if you created it just for this. A zone can be deleted once only its NS and SOA records are left. If the domain is registered with Route 53 and still in use, keep the zone, because deleting it stops the domain resolving.
# Deletes every object, then the bucket (for a bucket without versioning)
aws s3 rb s3://your-bucket-name --force
# Verify: should fail with NoSuchBucket
aws s3 ls s3://your-bucket-name
If you turned on versioning, rb --force leaves old object versions behind and the bucket can't be deleted; empty it from the console with Empty, which removes all versions.
Next Steps
- Give the deploy script credentials that can only touch this bucket and distribution: Secure Your AWS Account with IAM.
- Add a contact form without running a server: the Lambda and SES example in Your First Serverless Lambda Function.
- When the site needs server-side code, move to an instance: AWS EC2 Deployment Guide.



