Skip to content
Cloud & Infra Beginner Tutorial

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.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Aug 19, 2026 · 4 min read
Host a Static Site on S3 and CloudFront with Auto Cache Invalidation

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-export BUCKET.
  • An error occurred (IllegalLocationConstraintException) ... The unspecified location constraint is incompatible for the region specific endpoint this request was sent to. — you're outside us-east-1. Add --create-bucket-configuration LocationConstraint=<your-region> to create-bucket (only us-east-1 omits it), and use your region in the origin DomainName.
  • CloudFront URL returns 403 with <Code>AccessDenied</Code> — the bucket policy is missing or its AWS:SourceArn doesn't match your distribution ID. Re-check step 4, and confirm the distribution status is Deployed (aws cloudfront get-distribution --id $DIST_ID --query Distribution.Status).
  • zsh: no matches found: /* — your shell glob-expanded the invalidation path. Keep "/*" quoted, as in deploy.sh.

Next steps

  • Put a custom domain on it: request an ACM certificate (must be in us-east-1 for CloudFront), add it plus an Aliases block to the distribution, and point DNS at $CF_DOMAIN.
  • DefaultRootObject only rewrites the root URL — for pretty URLs like /about/, add a CloudFront Function that appends index.html to directory paths.
  • Run deploy.sh from CI (e.g. GitHub Actions with OIDC-based AWS credentials) so every merge to main ships automatically.
  • If you outgrow wildcard invalidations, fingerprint asset filenames (app.3f9c2b.js) and only invalidate the HTML.

Sources & further reading

  1. Restrict access to an Amazon S3 origin — docs.aws.amazon.com
  2. create-distribution - AWS CLI Command Reference — docs.aws.amazon.com
  3. Use managed cache policies — docs.aws.amazon.com
  4. Pay for file invalidation — docs.aws.amazon.com
  5. wait distribution-deployed - AWS CLI Command Reference — docs.aws.amazon.com
  6. Resolve the Amazon S3 IllegalLocationConstraintException bucket error — repost.aws
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading