Skip to content
EgyKode
Guided lab

Static Site on S3 + CloudFront

55 minBeginner

This creates billable resources. Run it in a dev environment and destroy it when you finish. Set a budget alarm first.

Success criteria

0 of 5

The scenario#

You need to put a static site on the internet, on HTTPS, cheaply, and without leaving a bucket open to the world.

This is the architecture EgyKode itself runs on — the page you are reading is served exactly this way, so the Terraform in infrastructure/terraform/production/ is the finished version of what you are about to build by hand.

Why not just make the bucket public?#

S3 can serve a website directly. It is also the single most common cause of real-world data exposure, it cannot do HTTPS on your own domain, and it has no edge cache. The pattern below keeps the bucket private and lets exactly one CloudFront distribution read it.

1. A private bucket#

Terminal
BUCKET="egykode-lab-$(date +%s)"
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"
 
printf '<!doctype html><h1>It works</h1>' > index.html
printf '<!doctype html><h1>Not found</h1>' > 404.html
aws s3 cp index.html "s3://$BUCKET/" --cache-control "public,max-age=0,must-revalidate"
aws s3 cp 404.html   "s3://$BUCKET/" --cache-control "public,max-age=0,must-revalidate"

Note the --cache-control at upload time. S3 stores it as object metadata and CloudFront honours it, so the header is set once at the source rather than patched at the edge.

2. Origin Access Control#

OAC is how CloudFront proves it is allowed to read a private bucket. It replaces the older Origin Access Identity.

Terminal
OAC_ID=$(aws cloudfront create-origin-access-control \
  --origin-access-control-config \
  "Name=${BUCKET}-oac,OriginAccessControlOriginType=s3,SigningBehavior=always,SigningProtocol=sigv4" \
  --query 'OriginAccessControl.Id' --output text)
echo "$OAC_ID"

3. The distribution#

Create it in the console or with aws cloudfront create-distribution, with:

  • Origin: the bucket's REST endpoint ($BUCKET.s3.us-east-1.amazonaws.com), not the website endpoint — the website endpoint is public and defeats the whole design.
  • Origin access: the OAC created above.
  • Viewer protocol policy: Redirect HTTP to HTTPS.
  • Compress objects automatically: on.
  • Default root object: index.html.

Then attach the bucket policy CloudFront prints for you, which allows s3:GetObject only for that distribution's ARN.

4. Verify, rather than assume#

Terminal
DOMAIN=$(aws cloudfront get-distribution --id "$DIST_ID" \
  --query 'Distribution.DomainName' --output text)
 
curl -sI "https://$DOMAIN/" | grep -iE 'HTTP/|content-encoding|cache-control|x-cache'

You are looking for four things:

HeaderExpectedIf it is missing
HTTP/2 200The site is servedCheck the default root object
Content-Encoding: brCompression is onEnable "compress objects automatically"
Cache-ControlWhat you set at uploadYou forgot --cache-control on s3 cp
X-Cache: Hit from cloudfrontThe edge is cachingFirst request is always a Miss; ask twice

And confirm the bucket really is private:

Terminal
curl -s -o /dev/null -w '%{http_code}\n' "https://$BUCKET.s3.amazonaws.com/index.html"
# 403 — correct. A 200 here means the bucket is public.

That 403 is the lab's most important result.

5. Cache headers that make sense#

Two kinds of file, two opposite requirements:

Terminal
# Fingerprinted assets — the name changes when the content does
aws s3 cp ./assets "s3://$BUCKET/assets" --recursive \
  --cache-control "public,max-age=31536000,immutable"
 
# HTML — the name never changes, so it must revalidate
aws s3 cp index.html "s3://$BUCKET/" \
  --cache-control "public,max-age=0,must-revalidate"

Getting this backwards is the classic mistake: cache HTML for a year and your next deploy is invisible for a year; revalidate assets on every request and you have paid for a CDN that does nothing.

When it goes wrong#

The failure is where the learning is. These are the ones that actually happen:

AccessDenied through CloudFront, not just S3

The bucket policy is missing or its AWS:SourceArn does not match this distribution. Re-copy the policy CloudFront generated.

The site loads but every path except / returns 403

S3 resolves no directory index through OAC. Either upload explicit index.html files per path, or add a CloudFront Function that rewrites /dir/ to /dir/index.html.

Changes do not appear after re-uploading

The edge is still serving a cached copy. aws cloudfront create-invalidation --paths '/*' — and check that HTML was uploaded with a revalidating Cache-Control.

Content-Encoding is absent

Compression is off on the behaviour, or the object's content type is not in CloudFront's compressible list.

Clean up#

Run this even if the lab is unfinished. Everything above is inside the free tier, but an account full of half-built experiments is how a surprise bill starts.

DestructiveThis removes real resources. Check which environment you are in first.

Terminal
aws cloudfront get-distribution-config --id $DIST_ID > dist.json  # note the ETag
Disable the distribution (set Enabled=false) and wait for Deployed
aws cloudfront delete-distribution --id $DIST_ID --if-match $ETAG
aws s3 rm s3://$BUCKET --recursive
aws s3api delete-bucket --bucket $BUCKET
aws cloudfront delete-origin-access-control --id $OAC_ID --if-match $OAC_ETAG
Verify: aws s3 ls | grep $BUCKET  # should print nothing

The concept behind it

Ready to try it without help?Do the challenge