AWS CloudFront Setup: 13 Steps, 60 Min [2026]

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.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

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.0 runtime that maps clean URLs to index.html files.
  • 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.

PlanMonthly priceIncluded requestsIncluded data transferIncluded S3 storage
Free$01 million100 GB5 GB
Pro$1510 million50 TB50 GB
Business$200125 million50 TB1 TB
Premium$1,000500 million50 TB5 TB
CustomNegotiatedNegotiatedNegotiatedNegotiated

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 itemRate or allowanceWhat it means for this project
Invalidation pathsFirst 1,000 paths per month free, then $0.005 per pathA wildcard path such as /* counts as one path, so a deploy a day stays free
CloudFront Functions2 million invocations free, then $0.10 per 1 millionThe URL rewrite function costs nothing at demo traffic
S3 to CloudFront transferNo additional charge for AWS originsCache misses do not add an origin-fetch fee
Amazon S3Usage-based, no minimum chargeStorage, requests and retrieval still bill separately outside plan credits
Security Savings BundleUp to 30% savings for a one-year spend commitmentOnly worth it once your monthly spend is steady
OACNo separate fee listedNormal 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.

RequirementVersion or settingWhy you need it
AWS CLILatest v2 releaseRuns every CloudFront, S3, ACM and Route 53 command
ShellBash 4+ or ZshHeredocs, process substitution and variable expansion
jqLatest 1.x releaseEdits the distribution config when you add a domain
curlAny current buildChecks response headers and cache status
IAM permissionsS3, CloudFront, ACM, Route 53 (domain step only)AdministratorAccess in a sandbox account is the easy route
CloudFront Functions runtimecloudfront-js-2.0Current runtime for new functions
Regionus-east-1 for the whole walkthroughACM certificates for CloudFront must live in us-east-1
Domain (optional)Hosted zone in Route 53Only 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.0 runtime, 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 /*.html a 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.com and 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/settings return 403 from a private bucket. A common fix is mapping 403 to /index.html with 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.css traps 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.

SymptomLikely causeFix
XML AccessDenied from the CloudFront URLBucket policy missing, or the SourceArn holds the wrong distribution IDRe-run Step 9 and compare the distribution ID in the policy with aws cloudfront list-distributions
403 on /about but 200 on /about/index.htmlFunction not published or not associated with viewer-requestPublish the function, check the LIVE ARN in the behavior, then wait for deployment
Old content after a deployEdge still holds the cached copyRun an invalidation for the path or /*, then check x-cache and age with curl
Domain missing from the certificate listCertificate requested outside us-east-1Request a new ACM certificate in us-east-1 and validate it
CNAMEAlreadyExists when adding an aliasAnother distribution already claims that hostnameRemove the alias from the old distribution or use a different name
PreconditionFailed on update-distributionStale or missing ETagRun get-distribution-config again and pass the fresh ETag to --if-match
Redirect to the S3 endpoint (307) on a new bucketOrigin domain uses the global S3 name before DNS settlesUse the regional domain bucket.s3.us-east-1.amazonaws.com as the origin
TooManyInvalidationsInProgressMore than 3,000 objects across running invalidation requestsWait for earlier requests to finish and batch paths into wildcards
Distribution stays InProgressNormal propagation or a slow waiterRe-run aws cloudfront wait distribution-deployed and check the status after a few minutes
Function test shows a syntax errorCode uses features outside the runtimeRun test-function in DEVELOPMENT and read FunctionErrorMessage before publishing
Fonts blocked by CORSNo CORS headers on responses from the edgeAttach a response headers policy that allows the needed origins
Direct bucket URL returns 200Block Public Access is off or a public ACL existsRe-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.

Sofia LindstrÃķm

Sofia LindstrÃķm

Editor-in-Chief

Sofia LindstrÃķm is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles