AWS CloudFront now sells flat-rate plans at $0, $15, $200 and $1,000 per month per distribution, and that changes how you should plan a static site on AWS. The plans bundle CDN delivery with security, DNS, logging, serverless edge compute and S3 storage credits, as laid out in this static hosting comparison. This tutorial, last checked on October 28, 2026, walks you through a complete CloudFront setup in 13 steps and about 60 minutes.
You will put a private Amazon S3 bucket behind CloudFront, lock it down with Origin Access Control (OAC), attach a managed cache policy, rewrite clean URLs with a CloudFront Function, add an optional custom domain, and ship updates with a single deploy script. If you are still deciding between CDNs, read our Cloudflare vs CloudFront test first, then come back here once AWS is your pick.
Everything runs from the AWS CLI, so the result is repeatable and easy to move into Terraform or CDK later. You get working commands, example output, a pitfalls list, a troubleshooting table and a bootstrap script you can paste into a fresh directory. Expect to spend roughly an hour, most of it waiting for CloudFront to deploy.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What You Will Build With AWS CloudFront and S3
The finished project serves a small static site from a private S3 bucket through a CloudFront distribution. Visitors never touch S3 directly. CloudFront signs each origin request with SigV4, and the bucket policy accepts only requests that come from your specific distribution. That pattern is the one AWS currently recommends for new S3 origins, and it replaces the older Origin Access Identity (OAI) approach that most blog posts still describe.
Here is the request path you are building, from browser to bucket and back:
Browser
| HTTPS request for /about
v
CloudFront edge location
| 1. viewer-request CloudFront Function rewrites /about -> /about/index.html
| 2. cache lookup using the managed CachingOptimized policy
| 3. on a miss: SigV4-signed GET to the origin via Origin Access Control
v
Private S3 bucket (Block Public Access ON, bucket policy allows only this distribution)
| object + Cache-Control header
v
CloudFront stores the object, adds security headers, returns it to the browser
AWS describes OAC as a way to restrict access to S3 origins, AWS Elemental origins and Lambda Function URLs so that content is reachable only through CloudFront. AWS also reportedly announced in October 2026 that CloudFront can use a Lambda Web Functions endpoint as an origin protected by OAC, with no additional feature fee. If you plan to add a dynamic backend later, our guide to deploying Express on Lambda Web Functions shows the origin side of that story.
By the end you will have these pieces running:
- A private S3 bucket with Block Public Access enabled and no website hosting turned on.
- An Origin Access Control using SigV4 signing set to always.
- A CloudFront Function on the
cloudfront-js-2.0runtime that maps clean URLs toindex.htmlfiles. - Managed cache and security header policies looked up by name, so nothing is hard-coded.
- A CloudFront distribution with HTTP/2 and HTTP/3, IPv6, compression and HTTPS-only viewers.
- A deploy script that syncs files with sensible Cache-Control headers and invalidates the cache.
The same layout works for a documentation site, a marketing page, a single-page app or a downloads bucket. The only parts that change are the cache headers you set at upload time and the function logic at the edge.
CloudFront Pricing in 2026: Flat-Rate Plans or Pay-as-You-Go
Before you create anything, decide how you want to pay. AWS now lists flat-rate CloudFront plans next to its classic pay-as-you-go model, and the pricing page was refreshed in the last days of October 2026. Per AWS, each plan is priced per distribution per month, carries no overage charges, and bundles CDN delivery with security, DNS, logging, serverless edge compute and S3 storage credits.
| Plan | Monthly price | Included requests | Included data transfer | Included S3 storage |
|---|---|---|---|---|
| Free | $0 | 1 million | 100 GB | 5 GB |
| Pro | $15 | 10 million | 50 TB | 50 GB |
| Business | $200 | 125 million | 50 TB | 1 TB |
| Premium | $1,000 | 500 million | 50 TB | 5 TB |
| Custom | Negotiated | Negotiated | Negotiated | Negotiated |
AWS says the Premium request allowance can be configured up to 6 billion for a new flat-rate price. One catch for this tutorial: the pricing table reportedly marks Custom Caching Rules as unavailable on the Free and Pro plans. That is why the walkthrough sticks to an AWS-managed cache policy, which works in every scenario. Check the plan matrix on the pricing page before you rely on a custom cache policy in production.
Pay-as-you-go remains available too. AWS lists an always-free tier of 1 TB of data transfer out, 10 million HTTP and HTTPS requests and 2 million CloudFront Functions invocations per month. Beyond that, charges depend on region, request volume and features. For a region-by-region view of the usage rates, the Costfluent CloudFront pricing handbook is a useful companion.
| Charge item | Rate or allowance | What it means for this project |
|---|---|---|
| Invalidation paths | First 1,000 paths per month free, then $0.005 per path | A wildcard path such as /* counts as one path, so a deploy a day stays free |
| CloudFront Functions | 2 million invocations free, then $0.10 per 1 million | The URL rewrite function costs nothing at demo traffic |
| S3 to CloudFront transfer | No additional charge for AWS origins | Cache misses do not add an origin-fetch fee |
| Amazon S3 | Usage-based, no minimum charge | Storage, requests and retrieval still bill separately outside plan credits |
| Security Savings Bundle | Up to 30% savings for a one-year spend commitment | Only worth it once your monthly spend is steady |
| OAC | No separate fee listed | Normal CloudFront and S3 charges still apply |
For the test traffic in this guide, a pay-as-you-go distribution stays inside the always-free allowances. If you pick a flat-rate plan in the console instead, the CLI commands below still describe the same architecture. I did not verify a CLI flag for plan selection, so confirm that step in the console or the current API reference.
Prerequisites and Tool Versions for the CloudFront Tutorial
You need an AWS account, a terminal with Bash or Zsh, and a few command-line tools. The commands assume AWS CLI version 2, which ships the aws cloudfront and aws s3api subcommands used below. AWS documents cloudfront-js-2.0 as the current CloudFront Functions runtime, with cloudfront-js-1.0 kept for legacy compatibility, so every function here targets 2.0.
| Requirement | Version or setting | Why you need it |
|---|---|---|
| AWS CLI | Latest v2 release | Runs every CloudFront, S3, ACM and Route 53 command |
| Shell | Bash 4+ or Zsh | Heredocs, process substitution and variable expansion |
| jq | Latest 1.x release | Edits the distribution config when you add a domain |
| curl | Any current build | Checks response headers and cache status |
| IAM permissions | S3, CloudFront, ACM, Route 53 (domain step only) | AdministratorAccess in a sandbox account is the easy route |
| CloudFront Functions runtime | cloudfront-js-2.0 | Current runtime for new functions |
| Region | us-east-1 for the whole walkthrough | ACM certificates for CloudFront must live in us-east-1 |
| Domain (optional) | Hosted zone in Route 53 | Only needed for Step 11 |
Verify your tools and your credentials before you create anything. A wrong profile is the most common reason a first deploy lands in the wrong account.
aws --version
jq --version
aws sts get-caller-identity
Example output (your account ID, user and versions will differ):
aws-cli/2.x.x Python/3.x.x Linux/6.8.0 exe/x86_64
jq-1.7
{
"UserId": "AIDAEXAMPLEUSERID",
"Account": "123456789012",
"Arn": "arn:aws:iam::123456789012:user/tutorial"
}
Use a sandbox account or a dedicated IAM user for this tutorial. CloudFront distributions are global resources, so a stray test distribution is easy to forget. If you would rather rent a small server and skip the CDN entirely, our AWS Lightsail setup guide covers that simpler route.
Steps 1-4: Variables, Site Files, Private Bucket and Upload
Step 1. Create the project directory and the variables file. Everything later reads from one .env file, which keeps the commands short and makes the deploy script trivial. The bucket name embeds your account ID so it stays globally unique without guesswork.
mkdir -p cf-demo/site/about cf-demo/site/assets cf-demo/function && cd cf-demo
export AWS_REGION=us-east-1
export PROJECT=cf-demo
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export BUCKET="${PROJECT}-${ACCOUNT_ID}-site"
cat > .env <<EOF
AWS_REGION=${AWS_REGION}
PROJECT=${PROJECT}
ACCOUNT_ID=${ACCOUNT_ID}
BUCKET=${BUCKET}
EOF
cat .env
Step 2. Write the site files. Four small files are enough to test routing, caching and the error page. The CSS file carries a version in its name on purpose. Long-lived assets should change names when their content changes, so you never have to invalidate them.
cat > site/index.html <<'EOF'
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>CloudFront demo</title>
<link rel="stylesheet" href="/assets/app.v1.css"></head>
<body><h1>Served by CloudFront</h1><p><a href="/about">About</a></p></body></html>
EOF
cat > site/about/index.html <<'EOF'
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>About</title>
<link rel="stylesheet" href="/assets/app.v1.css"></head>
<body><h1>About this demo</h1><p><a href="/">Home</a></p></body></html>
EOF
cat > site/404.html <<'EOF'
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Not found</title></head>
<body><h1>404</h1><p>That page does not exist.</p></body></html>
EOF
echo 'body{font-family:system-ui,sans-serif;max-width:40rem;margin:3rem auto}' > site/assets/app.v1.css
Step 3. Create the private S3 bucket. Create it in us-east-1 and switch on all four Block Public Access settings. Do not enable static website hosting. OAC works with the regular S3 REST endpoint, not with website endpoints, so the website feature would only get in the way. For any other region, add --create-bucket-configuration LocationConstraint=$AWS_REGION to the first command.
source .env
aws s3api create-bucket --bucket "$BUCKET" --region us-east-1
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api get-public-access-block --bucket "$BUCKET"
Step 4. Upload with the right Cache-Control headers. CloudFront respects the origin’s Cache-Control header. When the header is missing, the default behavior checks the origin again after 24 hours, which is far too long for HTML you edit often. Two sync passes solve it: versioned assets get a one-year immutable lifetime, and HTML gets five minutes.
aws s3 sync site/ "s3://$BUCKET/" --delete \
--exclude "*.html" \
--cache-control "public,max-age=31536000,immutable"
aws s3 sync site/ "s3://$BUCKET/" --delete \
--exclude "*" --include "*.html" \
--cache-control "public,max-age=300"
Example output from the two passes:
upload: site/assets/app.v1.css to s3://cf-demo-123456789012-site/assets/app.v1.css
upload: site/404.html to s3://cf-demo-123456789012-site/404.html
upload: site/about/index.html to s3://cf-demo-123456789012-site/about/index.html
upload: site/index.html to s3://cf-demo-123456789012-site/index.html
The bucket is private and populated. Nothing can reach it yet, which is exactly the state you want before the CDN exists.
Steps 5-7: Origin Access Control, a CloudFront Function and Managed Policies
Step 5. Create the Origin Access Control. OAC is a CloudFront resource that you attach to an origin. It tells CloudFront to sign every request to S3 with SigV4. Setting the signing behavior to always is the configuration AWS recommends for S3 origins, and sigv4 is the only valid signing protocol for this standard OAC model. The command saves the new ID to your .env file.
source .env
OAC_ID=$(aws cloudfront create-origin-access-control \
--origin-access-control-config "Name=${PROJECT}-oac,Description=OAC for ${BUCKET},SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3" \
--query 'OriginAccessControl.Id' --output text)
echo "OAC_ID=$OAC_ID" | tee -a .env
Step 6. Write, test and publish the CloudFront Function. An S3 REST origin knows nothing about directory index files. A request for /about asks S3 for an object with that exact key, which does not exist, and S3 answers with an error. A tiny viewer-request function fixes this at the edge by rewriting the URI before the cache lookup. AWS positions CloudFront Functions for exactly this kind of lightweight work: header manipulation, URL rewrites, redirects and cache-key normalization.
cat > function/rewrite.js <<'EOF'
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.charAt(uri.length - 1) === '/') {
request.uri = uri + 'index.html';
} else if (uri.indexOf('.') === -1) {
request.uri = uri + '/index.html';
}
return request;
}
EOF
aws cloudfront create-function --name "${PROJECT}-rewrite" \
--function-config Comment="Clean URL rewrite",Runtime=cloudfront-js-2.0 \
--function-code fileb://function/rewrite.js
Do not publish blind. CloudFront can run the function against a synthetic event in the DEVELOPMENT stage, which catches typos before any visitor sees them. Save a test event, run it, and read the output.
cat > function/test-event.json <<'EOF'
{
"version": "1.0",
"context": { "eventType": "viewer-request" },
"viewer": { "ip": "198.51.100.10" },
"request": {
"method": "GET",
"uri": "/about",
"headers": {},
"cookies": {},
"querystring": {}
}
}
EOF
ETAG=$(aws cloudfront describe-function --name "${PROJECT}-rewrite" --stage DEVELOPMENT --query ETag --output text)
aws cloudfront test-function --name "${PROJECT}-rewrite" --if-match "$ETAG" \
--stage DEVELOPMENT --event-object fileb://function/test-event.json \
--query 'TestResult.[FunctionErrorMessage,FunctionOutput]' --output text
Example output. The rewritten URI is the part to check:
None {"request":{"headers":{},"method":"GET","querystring":{},"cookies":{},"uri":"/about/index.html"}}
The function turned /about into /about/index.html. Publish it to the LIVE stage and store its ARN, because the distribution associates the LIVE version.
aws cloudfront publish-function --name "${PROJECT}-rewrite" --if-match "$ETAG"
FUNCTION_ARN=$(aws cloudfront describe-function --name "${PROJECT}-rewrite" \
--stage LIVE --query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)
echo "FUNCTION_ARN=$FUNCTION_ARN" | tee -a .env
Step 7. Look up the managed policies by name. AWS-managed cache and response headers policies have fixed names, so a query beats copying IDs from a blog post. For static S3 content, Managed-CachingOptimized is the sensible cache policy. Create a custom policy only when query strings, cookies, headers or TTLs demand it, and use an origin request policy for values that must reach S3 without fragmenting the cache key. Managed-SecurityHeadersPolicy adds common browser security headers without a line of code.
CACHE_POLICY_ID=$(aws cloudfront list-cache-policies --type managed \
--query "CachePolicyList.Items[?CachePolicy.CachePolicyConfig.Name=='Managed-CachingOptimized'].CachePolicy.Id | [0]" \
--output text)
HEADERS_POLICY_ID=$(aws cloudfront list-response-headers-policies --type managed \
--query "ResponseHeadersPolicyList.Items[?ResponseHeadersPolicy.ResponseHeadersPolicyConfig.Name=='Managed-SecurityHeadersPolicy'].ResponseHeadersPolicy.Id | [0]" \
--output text)
printf 'CACHE_POLICY_ID=%s\nHEADERS_POLICY_ID=%s\n' "$CACHE_POLICY_ID" "$HEADERS_POLICY_ID" | tee -a .env
If either variable prints None, your CLI session is on a different partition or the name changed. List the policies with --type managed and read the names yourself before you continue.
Steps 8-9: Create the Distribution and Lock the Bucket
Step 8. Create the CloudFront distribution. The config below uses the regional S3 domain name as the origin, attaches the OAC, applies both managed policies, associates the rewrite function on viewer-request, and maps S3’s 403 and 404 responses to your custom 404 page. S3 returns 403 for a missing key when the caller lacks s3:ListBucket, which is the normal state under OAC, so you need both error mappings.
source .env
cat > dist.json <<EOF
{
"CallerReference": "${PROJECT}-$(date +%s)",
"Comment": "${PROJECT} static site",
"Enabled": true,
"DefaultRootObject": "index.html",
"HttpVersion": "http2and3",
"IsIPV6Enabled": true,
"PriceClass": "PriceClass_100",
"Origins": {
"Quantity": 1,
"Items": [{
"Id": "s3-site",
"DomainName": "${BUCKET}.s3.${AWS_REGION}.amazonaws.com",
"OriginAccessControlId": "${OAC_ID}",
"S3OriginConfig": { "OriginAccessIdentity": "" }
}]
},
"DefaultCacheBehavior": {
"TargetOriginId": "s3-site",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
},
"Compress": true,
"CachePolicyId": "${CACHE_POLICY_ID}",
"ResponseHeadersPolicyId": "${HEADERS_POLICY_ID}",
"FunctionAssociations": {
"Quantity": 1,
"Items": [{ "EventType": "viewer-request", "FunctionARN": "${FUNCTION_ARN}" }]
}
},
"CustomErrorResponses": {
"Quantity": 2,
"Items": [
{ "ErrorCode": 403, "ResponsePagePath": "/404.html", "ResponseCode": "404", "ErrorCachingMinTTL": 60 },
{ "ErrorCode": 404, "ResponsePagePath": "/404.html", "ResponseCode": "404", "ErrorCachingMinTTL": 60 }
]
}
}
EOF
read DIST_ID CF_DOMAIN < <(aws cloudfront create-distribution \
--distribution-config file://dist.json \
--query 'Distribution.[Id,DomainName]' --output text)
printf 'DIST_ID=%s\nCF_DOMAIN=%s\n' "$DIST_ID" "$CF_DOMAIN" | tee -a .env
Example output of the last line (your IDs differ):
DIST_ID=E2EXAMPLE12345
CF_DOMAIN=d1234example.cloudfront.net
PriceClass_100 limits delivery to the cheapest edge regions. Drop that line if your audience is global and you want every location. If you would like to compare edge platforms before committing, our breakdown of Lambda Web Functions, Workers and Vercel shows how the compute side differs.
Step 9. Attach the bucket policy and wait for deployment. The distribution exists, but S3 still refuses it. The policy below grants s3:GetObject to the CloudFront service principal, and only when the request comes from your distribution’s ARN. That condition is the whole security model. Without it, any CloudFront distribution in any account could read your bucket.
source .env
cat > bucket-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::${BUCKET}/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DIST_ID}"
}
}
}]
}
EOF
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://bucket-policy.json
aws cloudfront wait distribution-deployed --id "$DIST_ID"
aws cloudfront get-distribution --id "$DIST_ID" --query 'Distribution.Status' --output text
The wait command blocks until CloudFront reports the distribution as deployed. Plan for several minutes. When it returns, the status line prints Deployed.
Step 10: Prove the Cache Works and the Bucket Is Closed
A setup is not finished until you have watched it behave. Request the home page twice and read the cache headers. The first response should say Miss from cloudfront because the edge location has nothing stored. The second should say Hit from cloudfront and carry an age header.
source .env
curl -sI "https://$CF_DOMAIN/" | grep -iE "^(HTTP|content-type|cache-control|x-cache|age|strict-transport|x-content-type)"
sleep 2
curl -sI "https://$CF_DOMAIN/" | grep -iE "^(HTTP|x-cache|age)"
Example output (the exact security headers depend on the managed policy):
HTTP/2 200
content-type: text/html
cache-control: public,max-age=300
x-cache: Miss from cloudfront
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
HTTP/2 200
x-cache: Hit from cloudfront
age: 2
Now test the clean URL, the missing page and the lockdown. The rewrite function should serve /about with a 200. A nonexistent path should return your custom 404 page. A direct request to the bucket must fail, because the bucket accepts nothing except signed requests from the distribution.
curl -s -o /dev/null -w "about: %{http_code}\n" "https://$CF_DOMAIN/about"
curl -s -o /dev/null -w "missing: %{http_code}\n" "https://$CF_DOMAIN/nope"
curl -s -o /dev/null -w "bucket: %{http_code}\n" "https://${BUCKET}.s3.${AWS_REGION}.amazonaws.com/index.html"
about: 200
missing: 404
bucket: 403
That three-line result is your acceptance test. If it differs, jump to the troubleshooting table. A 200 on the bucket line would mean public access is open somewhere, so stop and fix that before anything else. CloudFront also solves a different problem than an in-memory cache. Our comparison of ElastiCache, Azure Cache and Memorystore covers the layer you add behind dynamic origins, not in front of static ones.
Step 11: Add a Custom Domain With ACM and Route 53
The default cloudfront.net hostname works for testing, but production traffic needs your own name. This step is optional if you lack a domain. It requests a public certificate in us-east-1, proves ownership through a DNS record, attaches the certificate to the distribution and points the domain at CloudFront. CloudFront only accepts ACM certificates from us-east-1, no matter where your bucket lives.
source .env
export DOMAIN=www.example.com # replace with your hostname
export ROOT_ZONE=example.com # replace with your Route 53 zone
export ZONE_ID=$(aws route53 list-hosted-zones-by-name --dns-name "$ROOT_ZONE" \
--query 'HostedZones[0].Id' --output text | sed 's|/hostedzone/||')
CERT_ARN=$(aws acm request-certificate --region us-east-1 \
--domain-name "$DOMAIN" --validation-method DNS \
--query CertificateArn --output text)
sleep 15
read VNAME VVALUE < <(aws acm describe-certificate --region us-east-1 \
--certificate-arn "$CERT_ARN" \
--query 'Certificate.DomainValidationOptions[0].ResourceRecord.[Name,Value]' --output text)
cat > validation.json <<EOF
{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{
"Name":"${VNAME}","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"${VVALUE}"}]}}]}
EOF
aws route53 change-resource-record-sets --hosted-zone-id "$ZONE_ID" --change-batch file://validation.json
aws acm wait certificate-validated --region us-east-1 --certificate-arn "$CERT_ARN"
echo "Certificate issued: $CERT_ARN"
Next, edit the distribution. The pattern is always the same: fetch the config, keep the ETag, change the fields with jq, and send the whole config back with --if-match. Skipping the ETag is the classic cause of a PreconditionFailed error.
aws cloudfront get-distribution-config --id "$DIST_ID" > cfg.json
ETAG=$(jq -r .ETag cfg.json)
jq --arg d "$DOMAIN" --arg arn "$CERT_ARN" '.DistributionConfig
| .Aliases = {Quantity: 1, Items: [$d]}
| .ViewerCertificate = {ACMCertificateArn: $arn, SSLSupportMethod: "sni-only", MinimumProtocolVersion: "TLSv1.2_2021"}' \
cfg.json > cfg-new.json
aws cloudfront update-distribution --id "$DIST_ID" --if-match "$ETAG" --distribution-config file://cfg-new.json
aws cloudfront wait distribution-deployed --id "$DIST_ID"
Finally, create alias records. CloudFront uses a fixed hosted zone ID for alias targets, Z2FDTNDATAQYW2. Add both A and AAAA records, because the distribution has IPv6 enabled.
cat > alias.json <<EOF
{"Changes":[
{"Action":"UPSERT","ResourceRecordSet":{"Name":"${DOMAIN}","Type":"A",
"AliasTarget":{"HostedZoneId":"Z2FDTNDATAQYW2","DNSName":"${CF_DOMAIN}","EvaluateTargetHealth":false}}},
{"Action":"UPSERT","ResourceRecordSet":{"Name":"${DOMAIN}","Type":"AAAA",
"AliasTarget":{"HostedZoneId":"Z2FDTNDATAQYW2","DNSName":"${CF_DOMAIN}","EvaluateTargetHealth":false}}}
]}
EOF
aws route53 change-resource-record-sets --hosted-zone-id "$ZONE_ID" --change-batch file://alias.json
curl -sI "https://$DOMAIN/" | head -n 3
If the final curl prints HTTP/2 200, your own domain now serves the site over TLS with HTTP/3 available. Keep the root domain in mind. An apex name such as example.com needs its own alias records and a certificate that covers it, so request a second name on the same certificate or issue a separate one.
Step 12: Deploy Updates and Run a CloudFront Invalidation
Day-to-day work is a loop: edit files, upload, make sure the edge stops serving the old copy. Versioned assets never need the third part, because a new filename is a new cache key. HTML is where invalidation earns its keep. AWS states that invalidations generally reach edge locations in under two minutes, and the first 1,000 paths per month are free. Each extra path costs $0.005.
One request can invalidate a wildcard such as /*, and that counts as a single path. For a small site, one wildcard invalidation per deploy is the simplest correct answer. A distribution can have up to 3,000 objects in invalidation requests in progress at once, so avoid scripts that fire hundreds of overlapping requests. For API-style backends behind the same distribution, our walkthrough on building an AWS Lambda API shows what you would add as a second behavior.
cat > deploy.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source ./.env
aws s3 sync site/ "s3://$BUCKET/" --delete \
--exclude "*.html" \
--cache-control "public,max-age=31536000,immutable"
aws s3 sync site/ "s3://$BUCKET/" --delete \
--exclude "*" --include "*.html" \
--cache-control "public,max-age=300"
INV_ID=$(aws cloudfront create-invalidation --distribution-id "$DIST_ID" \
--paths "/*" --query 'Invalidation.Id' --output text)
echo "Invalidation $INV_ID started"
aws cloudfront wait invalidation-completed --distribution-id "$DIST_ID" --id "$INV_ID"
echo "Live: https://$CF_DOMAIN/"
EOF
chmod +x deploy.sh
sed -i 's/Served by CloudFront/Served by CloudFront (v2)/' site/index.html
./deploy.sh
Example output:
upload: site/index.html to s3://cf-demo-123456789012-site/index.html
Invalidation I3EXAMPLEINV started
Live: https://d1234example.cloudfront.net/
Reload the page and the heading should read v2 right away. If your edit still shows the old text, the browser may have its own cached copy from the five-minute Cache-Control header. Test with curl instead of the browser to separate the edge cache from the local one. Invalidations are free for the first 1,000 paths per month, and additional paths are charged at $0.005 per path, so keep that in mind if your site grows past the free allowance.
Step 13: Monitor Traffic and Keep the Bill Flat
CloudFront publishes distribution metrics to CloudWatch in us-east-1 under the AWS/CloudFront namespace, with a Region dimension of Global. Pull the last 24 hours of request counts and error rates straight from the CLI. The date syntax below is GNU, so macOS users should swap in date -u -v-24H.
source .env
for METRIC in Requests 4xxErrorRate 5xxErrorRate; do
STAT=Sum; [ "$METRIC" != "Requests" ] && STAT=Average
echo "== $METRIC ($STAT)"
aws cloudwatch get-metric-statistics --region us-east-1 \
--namespace AWS/CloudFront --metric-name "$METRIC" \
--dimensions Name=DistributionId,Value="$DIST_ID" Name=Region,Value=Global \
--start-time "$(date -u -d '24 hours ago' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
--period 3600 --statistics "$STAT" \
--query 'sort_by(Datapoints,&Timestamp)[-3:].[Timestamp,'"$STAT"']' --output text
done
Three habits keep the cost predictable. First, watch the 4xx and 5xx rates, because a sudden 403 spike usually means a bucket policy drifted or a deploy removed the wrong files. Second, set an AWS Budgets alert on the account so a traffic surge never surprises you. Third, review your spend once a month. The Everything Cloud guide to nine CloudFront cost levers is a quick checklist for that review.
AWS says the CloudFront Security Savings Bundle can cut charges by up to 30% in exchange for a one-year monthly-spend commitment, and it includes AWS WAF usage up to 10% of the committed spend. It only pays off once your spend is steady. For a learning project, stay on the free allowances or the Free plan and skip commitments. Also consider turning on standard access logs once you have real traffic. Reportedly, a July 14, 2026 update lets a CloudFront Function write custom values into those logs through cf.logCustomData(), so you can record routing or experiment decisions in the same record as the request. Confirm the call signature in the current function documentation before you use it.
The Complete Working Project in One Script
Here is the whole thing stitched into one file. Save it as bootstrap.sh in an empty directory next to a site/ folder (the four files from Step 2). It creates the bucket, the OAC, the function, the distribution and the bucket policy, writes .env, and then runs the first deploy. Run it only in a sandbox account, and delete the resources afterward using the cleanup block.
#!/usr/bin/env bash
set -euo pipefail
export AWS_REGION=us-east-1
PROJECT=cf-demo
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
BUCKET="${PROJECT}-${ACCOUNT_ID}-site"
mkdir -p function
cat > function/rewrite.js <<'EOF'
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.charAt(uri.length - 1) === '/') { request.uri = uri + 'index.html'; }
else if (uri.indexOf('.') === -1) { request.uri = uri + '/index.html'; }
return request;
}
EOF
# bucket
aws s3api create-bucket --bucket "$BUCKET" --region us-east-1 >/dev/null
aws s3api put-public-access-block --bucket "$BUCKET" --public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
# OAC
OAC_ID=$(aws cloudfront create-origin-access-control --origin-access-control-config \
"Name=${PROJECT}-oac,Description=OAC for ${BUCKET},SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3" \
--query 'OriginAccessControl.Id' --output text)
# function
aws cloudfront create-function --name "${PROJECT}-rewrite" \
--function-config Comment="Clean URL rewrite",Runtime=cloudfront-js-2.0 \
--function-code fileb://function/rewrite.js >/dev/null
ETAG=$(aws cloudfront describe-function --name "${PROJECT}-rewrite" --stage DEVELOPMENT --query ETag --output text)
aws cloudfront publish-function --name "${PROJECT}-rewrite" --if-match "$ETAG" >/dev/null
FUNCTION_ARN=$(aws cloudfront describe-function --name "${PROJECT}-rewrite" --stage LIVE \
--query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)
# managed policies
CACHE_POLICY_ID=$(aws cloudfront list-cache-policies --type managed \
--query "CachePolicyList.Items[?CachePolicy.CachePolicyConfig.Name=='Managed-CachingOptimized'].CachePolicy.Id | [0]" --output text)
HEADERS_POLICY_ID=$(aws cloudfront list-response-headers-policies --type managed \
--query "ResponseHeadersPolicyList.Items[?ResponseHeadersPolicy.ResponseHeadersPolicyConfig.Name=='Managed-SecurityHeadersPolicy'].ResponseHeadersPolicy.Id | [0]" --output text)
# distribution
cat > dist.json <<EOF
{"CallerReference":"${PROJECT}-$(date +%s)","Comment":"${PROJECT} static site","Enabled":true,
"DefaultRootObject":"index.html","HttpVersion":"http2and3","IsIPV6Enabled":true,"PriceClass":"PriceClass_100",
"Origins":{"Quantity":1,"Items":[{"Id":"s3-site","DomainName":"${BUCKET}.s3.${AWS_REGION}.amazonaws.com",
"OriginAccessControlId":"${OAC_ID}","S3OriginConfig":{"OriginAccessIdentity":""}}]},
"DefaultCacheBehavior":{"TargetOriginId":"s3-site","ViewerProtocolPolicy":"redirect-to-https",
"AllowedMethods":{"Quantity":2,"Items":["GET","HEAD"],"CachedMethods":{"Quantity":2,"Items":["GET","HEAD"]}},
"Compress":true,"CachePolicyId":"${CACHE_POLICY_ID}","ResponseHeadersPolicyId":"${HEADERS_POLICY_ID}",
"FunctionAssociations":{"Quantity":1,"Items":[{"EventType":"viewer-request","FunctionARN":"${FUNCTION_ARN}"}]}},
"CustomErrorResponses":{"Quantity":2,"Items":[
{"ErrorCode":403,"ResponsePagePath":"/404.html","ResponseCode":"404","ErrorCachingMinTTL":60},
{"ErrorCode":404,"ResponsePagePath":"/404.html","ResponseCode":"404","ErrorCachingMinTTL":60}]}}
EOF
read DIST_ID CF_DOMAIN < <(aws cloudfront create-distribution --distribution-config file://dist.json \
--query 'Distribution.[Id,DomainName]' --output text)
# bucket policy
cat > bucket-policy.json <<EOF
{"Version":"2012-10-17","Statement":[{"Sid":"AllowCloudFrontServicePrincipalReadOnly","Effect":"Allow",
"Principal":{"Service":"cloudfront.amazonaws.com"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::${BUCKET}/*",
"Condition":{"StringEquals":{"AWS:SourceArn":"arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DIST_ID}"}}}]}
EOF
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://bucket-policy.json
printf 'AWS_REGION=%s\nPROJECT=%s\nACCOUNT_ID=%s\nBUCKET=%s\nOAC_ID=%s\nDIST_ID=%s\nCF_DOMAIN=%s\n' \
"$AWS_REGION" "$PROJECT" "$ACCOUNT_ID" "$BUCKET" "$OAC_ID" "$DIST_ID" "$CF_DOMAIN" > .env
echo "Waiting for CloudFront to deploy..."
aws cloudfront wait distribution-deployed --id "$DIST_ID"
./deploy.sh
The cleanup block removes everything in dependency order. CloudFront refuses to delete an enabled distribution, so the script disables it first and waits. Run it when you finish experimenting so the test distribution does not linger.
source .env
aws cloudfront get-distribution-config --id "$DIST_ID" > cfg.json
ETAG=$(jq -r .ETag cfg.json)
jq '.DistributionConfig | .Enabled = false' cfg.json > cfg-off.json
aws cloudfront update-distribution --id "$DIST_ID" --if-match "$ETAG" --distribution-config file://cfg-off.json >/dev/null
aws cloudfront wait distribution-deployed --id "$DIST_ID"
ETAG=$(aws cloudfront get-distribution --id "$DIST_ID" --query ETag --output text)
aws cloudfront delete-distribution --id "$DIST_ID" --if-match "$ETAG"
aws cloudfront delete-origin-access-control --id "$OAC_ID" \
--if-match "$(aws cloudfront get-origin-access-control --id "$OAC_ID" --query ETag --output text)"
aws cloudfront delete-function --name "${PROJECT}-rewrite" \
--if-match "$(aws cloudfront describe-function --name "${PROJECT}-rewrite" --query ETag --output text)"
aws s3 rm "s3://$BUCKET" --recursive
aws s3api delete-bucket --bucket "$BUCKET"
Note the order. The distribution must be gone before the OAC and the function can be deleted, because both are still referenced while it exists. If you also completed Step 11, remove the Route 53 alias records and the ACM validation record too, and delete the certificate once no distribution uses it. A stale alias pointing at a deleted distribution is an easy subdomain takeover target, so clean DNS first when you tear down a real site. For a wider look at where containers or functions sit behind such an edge, the cost trade-offs in our ECS vs EKS vs Fargate comparison are a good next read.
Advanced CloudFront Tips for Production Sites
Infrastructure as code and newer origins
Once the CLI version works, move it into code. In the AWS CDK, the current guidance for standard S3 origins is S3BucketOrigin.withOriginAccessControl(), which creates the OAC with signing behavior set to always and wires the bucket policy for you. Reportedly, the older S3Origin construct is deprecated in recent CDK documentation, so new projects should not start with it. Terraform and OpenTofu users get the same shape from the OAC and distribution resources.
CloudFront keeps adding private-origin options. AWS reportedly added dual-stack IPv6 support for VPC origins, which lets IPv6 traffic reach private load balancers and EC2 instances without exposing them. When you outgrow static files, a second cache behavior for /api/* can point at a Lambda or container origin while the default behavior keeps serving S3. Our Lambda vs Azure Functions vs GCP comparison helps you pick that backend.
Edge logic and cache design
- Use KeyValueStore for redirects. A function that reads a CloudFront KeyValueStore must use the
cloudfront-js-2.0runtime, so keep every new function on 2.0. That lets you update a redirect map without redeploying code. - Normalize the cache key. Sort or strip query strings that do not change content, such as tracking parameters. Fewer cache keys mean a higher hit ratio and fewer origin fetches.
- Separate behaviors by path. Give
/assets/*a long TTL and/*.htmla short one, instead of relying only on object headers. - Add a response headers policy for CORS if fonts or JSON files load from another origin. Do it in the policy, not in S3 CORS, so the cache key stays small.
- Protect previews. Put a basic-auth check in a viewer-request function for staging distributions, and never reuse that function on production.
- Consider Lambda@Edge only when needed. CloudFront Functions handle lightweight transformations. Heavier work, network calls or body access still belong in a different tool.
Finally, version your OAC and bucket policy in source control. The bucket policy is the single most security-sensitive file in the project, and reviewing it in a pull request beats discovering a drift in an incident.
Common Pitfalls With CloudFront and S3
These are the mistakes that cost people the most time. Each one has a short fix.
- Pointing OAC at an S3 website endpoint. OAC and the older OAI apply to regular S3 bucket origins, not website endpoints. Use the REST domain
bucket.s3.region.amazonaws.comand handle index files with a function. - Following an OAI tutorial from 2021. OAI still exists as a legacy compatibility option, but AWS recommends OAC for new S3 origins. Copying an old guide gives you an outdated setup.
- Requesting the ACM certificate in the wrong Region. CloudFront reads certificates from us-east-1 only. A certificate in eu-west-1 never appears in the distribution settings.
- Forgetting Cache-Control and trusting the default. With no header, CloudFront checks the origin again after 24 hours, so a fixed typo can linger for a day. The InfoWok guide to CDN cache keys and invalidation explains why headers beat manual purges.
- Invalidating on every file change. Paths past the first 1,000 per month cost $0.005 each. Use versioned filenames for assets and one wildcard for HTML.
- Breaking single-page app routes. Deep links such as
/dashboard/settingsreturn 403 from a private bucket. A common fix is mapping 403 to/index.htmlwith a 200 status, but that also hides real API errors, as this DEV Community write-up on React routes behind CloudFront explains. Prefer a route-aware function over a blanket error rule. - Editing a distribution without its ETag. Every update needs the current ETag in
--if-match. Fetch the config again right before you change it. - Shipping a long TTL on unversioned files. A one-year immutable header on
app.csstraps users on the old file. Rename the file or shorten the TTL. - Leaving test distributions enabled. They are global and easy to forget. Tag them, and run the cleanup block when the experiment ends.
Troubleshooting CloudFront Setup Errors
Start with the response headers, because x-cache, via and the status code narrow most failures to one layer. For more cloud walkthroughs in the same style, browse the cloud computing section.
| Symptom | Likely cause | Fix |
|---|---|---|
| XML AccessDenied from the CloudFront URL | Bucket policy missing, or the SourceArn holds the wrong distribution ID | Re-run Step 9 and compare the distribution ID in the policy with aws cloudfront list-distributions |
| 403 on /about but 200 on /about/index.html | Function not published or not associated with viewer-request | Publish the function, check the LIVE ARN in the behavior, then wait for deployment |
| Old content after a deploy | Edge still holds the cached copy | Run an invalidation for the path or /*, then check x-cache and age with curl |
| Domain missing from the certificate list | Certificate requested outside us-east-1 | Request a new ACM certificate in us-east-1 and validate it |
| CNAMEAlreadyExists when adding an alias | Another distribution already claims that hostname | Remove the alias from the old distribution or use a different name |
| PreconditionFailed on update-distribution | Stale or missing ETag | Run get-distribution-config again and pass the fresh ETag to --if-match |
| Redirect to the S3 endpoint (307) on a new bucket | Origin domain uses the global S3 name before DNS settles | Use the regional domain bucket.s3.us-east-1.amazonaws.com as the origin |
| TooManyInvalidationsInProgress | More than 3,000 objects across running invalidation requests | Wait for earlier requests to finish and batch paths into wildcards |
| Distribution stays InProgress | Normal propagation or a slow waiter | Re-run aws cloudfront wait distribution-deployed and check the status after a few minutes |
| Function test shows a syntax error | Code uses features outside the runtime | Run test-function in DEVELOPMENT and read FunctionErrorMessage before publishing |
| Fonts blocked by CORS | No CORS headers on responses from the edge | Attach a response headers policy that allows the needed origins |
| Direct bucket URL returns 200 | Block Public Access is off or a public ACL exists | Re-apply the four Block Public Access settings and remove public ACLs |
If none of these match, enable standard logging and read the status codes for a single request ID. Repeat the failing request with curl -v and note the x-amz-cf-id header, which AWS support can trace.
Frequently Asked Questions
Is AWS CloudFront free to use? There is a $0 Free plan with 1 million requests, 100 GB of data transfer and 5 GB of S3 storage credits per distribution. Pay-as-you-go also keeps an always-free allowance of 1 TB of transfer out, 10 million requests and 2 million function invocations a month. Anything past those limits bills by usage or by plan.
What is the difference between OAC and OAI? Origin Access Control is the current mechanism. It signs requests with SigV4, supports S3 and other origin types, and works with newer features. Origin Access Identity is the older approach. AWS still lists it as a legacy compatibility option but recommends OAC for new S3 origins.
How long does a CloudFront invalidation take? AWS states that invalidations generally reach edge locations in under two minutes. Broader configuration changes usually take several minutes. Use aws cloudfront wait invalidation-completed in scripts that must block until the purge ends.
Can CloudFront serve a private S3 bucket? Yes. Keep Block Public Access on, attach an OAC to the origin, and allow only your distribution’s ARN in the bucket policy. Requests straight to the bucket then fail with 403, while requests through CloudFront succeed.
Should I use CloudFront Functions or Lambda@Edge? Use CloudFront Functions for lightweight viewer-request and viewer-response logic such as rewrites, redirects and header changes. Reach for Lambda@Edge when the job needs more compute, longer execution or other AWS service calls.
Do I need Route 53 for a custom domain? No. Any DNS provider works if you can create a validation CNAME and point your hostname at the CloudFront domain. Route 53 just makes the alias records and the certificate validation faster to script.
Can CloudFront front a Lambda function instead of S3? Yes. AWS reportedly announced Lambda Web Functions as a CloudFront origin with OAC support in October 2026. The static-site steps here still apply for the S3 part, and you add a second origin and cache behavior for the function path.
Which cache policy should I choose for static files? Start with the AWS-managed CachingOptimized policy and set Cache-Control headers at upload time. Create a custom policy only when you must include query strings, headers or cookies in the cache key.





