Deploy a Static Site with Global CDN Caching Using S3 and CloudFront
Host a static site on S3 behind CloudFront for HTTPS and fast global delivery, all from the CLI.
What you'll build
A static site served over HTTPS from a private S3 bucket, cached at CloudFront's edge locations worldwide. Everything happens in the terminal, and the whole thing fits in a free-tier account.
Prerequisites
- An AWS account with credentials configured (
aws configureor SSO). Your IAM user or role needs S3 and CloudFront permissions;AdministratorAccesson a sandbox account is fine for this. - AWS CLI v2. Verified against 2.36.x; check yours with
aws --version. - A POSIX shell (macOS, Linux, or WSL on Windows). The heredocs below won't work in PowerShell.
We'll keep the bucket private and let CloudFront reach it through an origin access control (OAC). That's the current AWS-recommended setup, and it means you never touch S3 public access settings or the legacy website endpoint.
1. Create the bucket and upload the site
S3 bucket names are globally unique, so bake some randomness in:
export BUCKET=sf-static-demo-$RANDOM$RANDOM
aws s3 mb s3://$BUCKET --region us-east-1
Create a minimal site and sync it up:
mkdir site
cat > site/index.html <<'HTML'
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Hello from the edge</title></head>
<body><h1>Served by CloudFront</h1></body>
</html>
HTML
aws s3 sync site/ s3://$BUCKET
New buckets block public access by default. Leave it that way; CloudFront will be the only reader.
2. Create an origin access control
The OAC signs every request CloudFront sends to your bucket, which is what lets the bucket stay private:
OAC_ID=$(aws cloudfront create-origin-access-control \
--origin-access-control-config \
Name=$BUCKET-oac,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3 \
--query OriginAccessControl.Id --output text)
echo $OAC_ID
3. Create the CloudFront distribution
The config below points at the bucket's regional REST endpoint (not the website endpoint), attaches the OAC, redirects HTTP to HTTPS, and uses AWS's managed CachingOptimized policy. That policy ID is a global constant, the same in every account:
cat > dist-config.json <<EOF
{
"CallerReference": "$BUCKET-$(date +%s)",
"Comment": "Static site demo",
"Enabled": true,
"DefaultRootObject": "index.html",
"Origins": {
"Quantity": 1,
"Items": [{
"Id": "s3-origin",
"DomainName": "$BUCKET.s3.us-east-1.amazonaws.com",
"OriginAccessControlId": "$OAC_ID",
"S3OriginConfig": { "OriginAccessIdentity": "" }
}]
},
"DefaultCacheBehavior": {
"TargetOriginId": "s3-origin",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"
}
}
EOF
DIST_ID=$(aws cloudfront create-distribution \
--distribution-config file://dist-config.json \
--query Distribution.Id --output text)
DOMAIN=$(aws cloudfront get-distribution --id $DIST_ID \
--query Distribution.DomainName --output text)
echo "$DIST_ID -> https://$DOMAIN"
The empty OriginAccessIdentity is intentional. The API requires the field, and an empty string tells CloudFront you're using OAC instead of the legacy OAI mechanism.
4. Let CloudFront read the bucket
The distribution exists but can't fetch anything yet. Add a bucket policy that allows the CloudFront service principal, scoped to this one distribution so no other account's distribution can piggyback on your bucket:
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
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
Now wait for the distribution to roll out to the edge. This usually takes about five minutes:
aws cloudfront wait distribution-deployed --id $DIST_ID
Verify it works
curl -sI https://$DOMAIN
Expected output (trimmed):
HTTP/2 200
content-type: text/html
server: AmazonS3
x-cache: Miss from cloudfront
via: 1.1 xxxxxxxxxxxx.cloudfront.net (CloudFront)
Run it a second time and x-cache flips to Hit from cloudfront. That's the edge cache doing its job. Open https://$DOMAIN in a browser and you should see "Served by CloudFront" with a valid TLS certificate.
Troubleshooting
make_bucket failed: ... An error occurred (BucketAlreadyExists): someone else owns that name globally. Re-run the export BUCKET= line to roll a new one, then retry.
CloudFront returns 403 with <Code>AccessDenied</Code>: the bucket policy is missing or its AWS:SourceArn doesn't match your distribution ID. Print $DIST_ID, compare it to bucket-policy.json, and re-run put-bucket-policy. If you fixed the policy in the last minute or two, wait: S3 policy changes take a moment to apply, and CloudFront may have cached the 403 briefly.
You updated files but the site didn't change: CachingOptimized caches objects for 24 hours by default. After aws s3 sync, purge the cache:
aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/*"
The S3 URL itself returns AccessDenied: expected. https://$BUCKET.s3.us-east-1.amazonaws.com/index.html is supposed to fail; the bucket only answers signed requests from your distribution. Always use the CloudFront domain.
Next steps
Put a custom domain on it: request a certificate in ACM (it must live in us-east-1 for CloudFront), add the domain to the distribution's Aliases, and point DNS at the distribution with a CNAME or Route 53 alias. One gotcha with OAC worth knowing before you build a multi-page site: DefaultRootObject only covers the root, so /about/ won't resolve to /about/index.html. A ten-line CloudFront Function on the viewer-request event fixes that. And when you're done experimenting, disable and delete the distribution before removing the bucket, or CloudFront will hold a reference to a bucket that no longer exists.
Sources & further reading
- Restrict access to an Amazon S3 origin — docs.aws.amazon.com
- Use managed cache policies — docs.aws.amazon.com
- create-origin-access-control - AWS CLI Command Reference — docs.aws.amazon.com
- create-distribution - AWS CLI Command Reference — docs.aws.amazon.com
- wait distribution-deployed - AWS CLI Command Reference — docs.aws.amazon.com
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 6
neat walkthrough for the basic setup. curious how they handle cache invalidation in practice though
did this exact setup last month for a client site and honestly the free tier limits almost bit us — cloudfront free tier is generous but if you're doing any real traffic, budget for the data transfer out. one thing the guides never mention: invalidating the cache via cli is your friend when you're iterating fast. we ended up just burning through invalidations instead of waiting for ttls because time to market mattered more than the $0.005 per request cost
hold on—saying the whole thing fits in a free tier is misleading. s3 requests and cloudfront data transfer out add up fast once you're actually serving traffic, and once you hit that limit you're stuck with surprise bills. the setup is solid but that framing glosses over the cost envelope that catches people.
@data_eng_dee nailed it. we learned this the hard way in 2019 when a static asset got picked up by a crawler and blew through the free tier in a weekend. the setup itself is right, but "free tier" in the readme should probably say "free tier to experiment" with a link to the pricing calculator. one line of context saves someone's month.
@data_eng_dee nailed it. 'free tier' means first 20 requests and then you learn AWS accounting the hard way.
the 'whole thing fits in free tier' bit is misleading though—cloudfront data transfer out starts charging pretty fast once you get real traffic. s3 storage is basically free, but if you're actually using the cdn for anything beyond toy projects, you're looking at egress costs that'll creep up quick. still a solid setup, just want folks to know what they're signing up for.