Host a Static Site on S3 and CloudFront with Auto Cache Invalidation
Serve a private S3 bucket through CloudFront and script deploys that bust the CDN cache automatically.
What you'll build / learn
A static site served from a private Amazon S3 bucket through CloudFront, plus a one-command deploy script that syncs your files and invalidates the CDN cache so every push goes live immediately.
Prerequisites
- An AWS account and an IAM user/role with S3 and CloudFront permissions.
- AWS CLI v2, configured via
aws configure. Commands below verified against AWS CLI 2.35. - macOS or Linux with bash/zsh (Windows: use WSL).
- This walkthrough uses
us-east-1. Only one command changes for other regions — see Troubleshooting.
1. Create the bucket and upload the site
Bucket names are globally unique, so pick your own. Keep the defaults AWS gives new buckets (Block Public Access on, ACLs disabled) — CloudFront will be the only reader.
export BUCKET=my-site-$USER-$(date +%s)
aws s3api create-bucket --bucket $BUCKET
mkdir -p site
cat > site/index.html <<'EOF'
<!doctype html>
<html><body><h1>Deployed via S3 + CloudFront</h1></body></html>
EOF
aws s3 sync site/ s3://$BUCKET
2. Create an origin access control
Origin access control (OAC) lets CloudFront sign its requests to S3, so the bucket stays private:
export OAC_ID=$(aws cloudfront create-origin-access-control \
--origin-access-control-config \
Name=oac-$BUCKET,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3 \
--query OriginAccessControl.Id --output text)
3. Create the CloudFront distribution
create-distribution needs a full config document. The empty OriginAccessIdentity is required — it's the legacy field, blanked out because we're using OAC. CachePolicyId is AWS's managed CachingOptimized policy (24h default TTL, compression on), the right default for static assets.
cat > dist-config.json <<EOF
{
"CallerReference": "$BUCKET-$(date +%s)",
"Comment": "static site $BUCKET",
"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
export DIST_ID=$(aws cloudfront create-distribution \
--distribution-config file://dist-config.json \
--query Distribution.Id --output text)
export CF_DOMAIN=$(aws cloudfront get-distribution --id $DIST_ID \
--query Distribution.DomainName --output text)
echo "https://$CF_DOMAIN"
4. Let CloudFront read the bucket
Grant the CloudFront service principal s3:GetObject, scoped to this one distribution via AWS:SourceArn:
export ACCOUNT=$(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:distribution/$DIST_ID" }
}
}]
}
EOF
aws s3api put-bucket-policy --bucket $BUCKET --policy file://bucket-policy.json
5. Write the deploy script
sync --delete mirrors your local folder (removing dead files), then one wildcard invalidation busts the whole cache. /* counts as a single invalidation path, and your first 1,000 paths per month are free — so invalidating everything on each deploy costs nothing at normal push frequency.
cat > deploy.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${BUCKET:?set BUCKET}" "${DIST_ID:?set DIST_ID}"
aws s3 sync site/ "s3://$BUCKET" --delete
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"
EOF
chmod +x deploy.sh
Verify it works
First deploy takes a few minutes to propagate; wait, then fetch:
aws cloudfront wait distribution-deployed --id $DIST_ID
curl -s https://$CF_DOMAIN
Expected:
<!doctype html>
<html><body><h1>Deployed via S3 + CloudFront</h1></body></html>
Now change the headline in site/index.html, run ./deploy.sh, and the output should include an invalidation like:
{
"Invalidation": {
"Id": "I2J0V9O5H1KZKG...",
"Status": "InProgress",
...
}
}
Invalidations typically complete in under a minute — re-run the curl and you'll see the new content.
Troubleshooting
An error occurred (BucketAlreadyExists) when calling the CreateBucket operation— bucket names are global across all AWS accounts. Pick a more unique name and re-exportBUCKET.An error occurred (IllegalLocationConstraintException) ... The unspecified location constraint is incompatible for the region specific endpoint this request was sent to.— you're outsideus-east-1. Add--create-bucket-configuration LocationConstraint=<your-region>tocreate-bucket(onlyus-east-1omits it), and use your region in the originDomainName.- CloudFront URL returns
403with<Code>AccessDenied</Code>— the bucket policy is missing or itsAWS:SourceArndoesn't match your distribution ID. Re-check step 4, and confirm the distribution status isDeployed(aws cloudfront get-distribution --id $DIST_ID --query Distribution.Status). zsh: no matches found: /*— your shell glob-expanded the invalidation path. Keep"/*"quoted, as indeploy.sh.
Next steps
- Put a custom domain on it: request an ACM certificate (must be in
us-east-1for CloudFront), add it plus anAliasesblock to the distribution, and point DNS at$CF_DOMAIN. DefaultRootObjectonly rewrites the root URL — for pretty URLs like/about/, add a CloudFront Function that appendsindex.htmlto directory paths.- Run
deploy.shfrom CI (e.g. GitHub Actions with OIDC-based AWS credentials) so every merge tomainships automatically. - If you outgrow wildcard invalidations, fingerprint asset filenames (
app.3f9c2b.js) and only invalidate the HTML.
Sources & further reading
- Restrict access to an Amazon S3 origin — docs.aws.amazon.com
- create-distribution - AWS CLI Command Reference — docs.aws.amazon.com
- Use managed cache policies — docs.aws.amazon.com
- Pay for file invalidation — docs.aws.amazon.com
- wait distribution-deployed - AWS CLI Command Reference — docs.aws.amazon.com
- Resolve the Amazon S3 IllegalLocationConstraintException bucket error — repost.aws
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.